Compare commits

..
10 Commits
Author SHA1 Message Date
jason.woltje 9f0fcf8690 test(organization): pin route refusal variable names (#1873) 2026-11-10 21:21:36 +00:00
jason.woltje c7b588289f fix(organization): keep var values out of refusals (#1873) 2026-11-10 21:21:36 +00:00
jason.woltje aa9f936690 fix(webui): P2 system types in a side-by-side pane only show, never switch (#1873)
U5 draws the organization dashboard into a side-by-side pane, and the
System types section came with it: Enable and Disable buttons that wrote
to the selected organization whatever the pane showed, a heading id
repeated in both panes, and a Settings Open button that focused the
first pane's section. A pane now shows its own organization's types
with no switch and says to change them from the full view, as U5 does
for the bus restart. The heading id carries the pane's prefix, Open
focuses the section in its own dashboard, and the click handler and
the post-write repaint ignore panes.
2026-11-10 21:11:14 +00:00
jason.woltje 49a8d2983e test(webui): answer the project system-types read in the W15 integration fixture (#1873) 2026-11-10 21:11:14 +00:00
jason.woltje d630f815c2 fix(webui): P2 review fixes for system types (#1873)
Recheck the session after the read so a sign-out dispatches no write.
Take the enabled set and its revision from one plugins read. Let the
owner clear an inactive-only selection when nothing is enabled. Keep
role tools as the hard ceiling in the toolset design, name only
vocabulary tools in the mapping, and intersect mission and task over
the final set.
2026-11-10 21:11:14 +00:00
jason.woltje 14f1b3b2b1 feat(webui): P2 system types on the org dashboard and project page, toolset design (#1873)
The org dashboard lists the installed system-type modules with their own
labels and descriptions, marks which are enabled, and gives the owner an
enable or disable control through plugins.set behind the owner fence, with
the revision and the transaction receipt. The project page selects from the
organization's enabled set through project.system-types.set and shows a
disabled selection as inactive, as system-types.console reports. Both routes
go through the per-org Console call path and its admission checks.

docs/plans/2026-11-10_system-type-toolsets-design.md proposes how a launch
turns a project's active system types into toolsets under the role, mission
and task ceilings. Design only; no roles/ or contract change is applied.
2026-11-10 21:11:14 +00:00
jason.woltje f98f278fbb docs: roles design review fixes (#1873)
Route merge, policy, hire, fire and credential decisions to the human and
keep merge and policy cross-role only. A person-started hire is a decision
record. Restructure the usability table by who acts, list template roles
explicitly, add the arbiter threat row and slice tests.
2026-11-10 15:00:43 -06:00
jason.woltje c6e341e92e docs: roles, rails and hire as a decision design (#1873)
Roles become templates plus organization copies built from known parts. A hire is a decision the chief raises and a human with org.hire approves. Rails listed with their enforcement points. Proposal only: no change to AGENTS.md, roles/, contracts/ or code.
2026-11-10 14:50:04 -06:00
jason.woltje 50be333552 fix(organization): address accounts review and rollback edge cases (#1873) 2026-11-10 20:43:00 +00:00
jason.woltje 3bb0f70acc feat(organization): add locked instance accounts store and CLI (#1873) 2026-11-10 20:43:00 +00:00
33 changed files with 2515 additions and 84 deletions

No files matched your search

@@ -0,0 +1,451 @@
# Roles, rails and hiring
Date: 2026-11-10. Lane org-roles-design, issue #1873. Status: proposed, doc
only. Nothing here changes `AGENTS.md`, `roles/`, `contracts/`, rights policy
or code. The slices in section 12 that touch role or rights policy land only
with Jason's approval.
Read with `docs/plans/2026-11-10_agent-levels-design.md` (levels, isolation,
Phase A) and `docs/plans/2026-11-10_accounts-design.md` (rights, host trust
boundary). This design replaces agent-levels section 7 and the A1 roles patch,
which widened the chief's `hireable` list. That patch is withdrawn and nothing
here extends it.
Line numbers are against `origin/refactor` at `50be33355`.
## 1. Rulings
Jason, 2026-11-10:
1. Who hires: the chief asks, a human approves. A hire is a decision, not a direct write.
2. Which roles, verbatim: "Chief should be capable of hiring ANY role with any capacity. This will include custom roles invented by the user and the chief at any time."
3. No cap on the number of hires.
4. Timing: lands with A4.
5. Templates, verbatim: "Mosaic Stack should ship role templates for the most common roles. These stay within the system as templates. Chief and user have the capability to import the roles from template and customize them as needed. New roles outside of the supplied templates must follow a set of rules from within the system to prevent an absolute destruction of the system's workings. We need a set of primitives available to the agent roles to allow for adherence. A new role constructed by chief and the user must plugin and follow the rules with the RBAC and other tools available. The agent will have an arbitrary set of tools specified by chief and/or the user. The agent will also have a personality and other traits specified by the user. The system must remain unopinionated upon the opinionated basal code provided. We provide the operational rails and allow an open system to build upon it."
6. Ceiling: the approval is the ceiling. A hire may carry grants above the chief's own, but the decision lists those grants separately and flags them.
7. Approver: a new grantable right `org.hire` (owner yes, member no) in the M1 rights table.
8. Only the chief raises hires. No hire chains: a hired agent cannot hire.
9. Rails, summarized (full text in the lead tracker; ask nothing, design from this): a base of inalienable rails the system needs to function, with everything else customizable on top. The smallest working system is user, assistant and chief; the chief staffs the rest from the user's plan. Start with the fewest agents, add agents as roles are needed, and keep configurations apart with organizations and projects. Full extensibility: modules and plugins, preconfigured systems for any use (software factory, writing workshop, video production, business, home, health), installed from GitHub, a zip or another source, and maybe a marketplace later. Flexibility comes from a set of primitives controlled by routing, agent configuration and plugins. Jason: "The Venn diagram for usability and security/rails needs to be understood."
10. Credentials: the hire decision lists each credential and scope the agent needs; approving the decision binds them.
11. Template updates: an imported role is a frozen copy. When the shipped template changes, the system offers a diff; it never changes the copy by itself.
12. Personality: the chief may draft personality and traits inside the hire decision. After the hire only a human changes them.
Also binding, Jason 2026-11-07: the system is modules joined by documented
calls. Swapping or disabling one must not break core, and adding one must not
need a core rewrite (`docs/plans/2026-11-07_organizations-owner-rulings.md:43-47`).
## 2. Today
- A role is `roles/<name>.json` plus its `.md` contract. Version 2 allows
only known keys, so an unknown field such as `personality` refuses
(`packages/organization/src/role.mjs:21,190`). Tools come from one closed
core list (`role.mjs:24-29`, `packages/organization/src/vocabulary.mjs:142`).
Actions come from `ACTIONS` (`vocabulary.mjs:11-65`); an unknown one
refuses, and so does one outside `ROUTED`, because those are hard waits the
human always decides (`role.mjs:42-43`, `vocabulary.mjs:109`).
- The chief hires by writing directly. `instance.hire` checks that the caller
is the instance named in `launch.by` (`packages/bus/src/broker.mjs:6193`)
and holds `role.hire`, refuses kept names (`broker.mjs:6200`), then runs
`#hireable` (`broker.mjs:6291-6306`). That needs a version 2 definition whose
every action is on `HIRE_ALLOWED` (`vocabulary.mjs:114-121`, checked by
`unhireableAction` at `vocabulary.mjs:127`), named in the chief's own
`hireable` list, and not in `launch.noHire`.
- The shipped chief's list is empty (`roles/chief-of-staff.json:13`), so the
chief can hire nothing today. Its contract says so
(`roles/chief-of-staff.md:17`).
- Creative definitions are human-only. The writer refuses them for the chief
(`packages/organization/src/instance-settings.mjs:365-367`) and the role
check refuses them in a `hireable` list (`role.mjs:86-97`).
- A hire writes `roles[name] = { definition, vars? }` and appends the name to
`launch.instances` (`instance-settings.mjs:354-391`). Its vars may set only
`model` and `limits.*` (`broker.mjs:216-221`). So `launch` refuses the new
instance with `no-route` until a human sets harness, model and thinking
(`packages/harness/src/bundle.mjs:96`, `roles/chief-of-staff.md:53-58`).
- Fire revokes the instance's service credentials and delists it
(`instance-settings.mjs:396-414`).
- Rights for people are a separate table: `RIGHTS`, the `ROLE_RIGHTS`
bundles and `canGrant` (`packages/organization/src/org-rights.mjs:6-17,21-26,122-129`).
It has no hire right yet.
## 3. The rails
A rail is a rule no role, template, plugin, approval or setting can change
while the system runs. Only a reviewed commit changes one. Each row says where
the code enforces it today and which slice closes the gap.
| # | Rail | Enforced today | Gap, and the slice that closes it |
|---|---|---|---|
| R1 | Secrets never enter the repository, images, role files, decision text or the trail. A credential reaches an agent only through a human-approved binding, scoped and revocable. | Message and record bodies pass `credentials.assertClean` (`packages/bus/src/runtime.mjs:833`, `broker.mjs:840,1313,2081`). Credential verbs are human-only (`broker.mjs:6144`). `credential.mint` decisions route to the human (`broker.mjs:2266-2268,2368`). Fire revokes (`instance-settings.mjs:396`). | Role definitions, personality text and hire decisions are not scanned. P1 runs the same check on every string in a composition and a hire decision. |
| R2 | The trail and run records are write-once. | `events` and `decision_events` refuse update, delete and replace (`packages/bus/src/migrations/3b.sql:131-132,137-138,144,147`). | None. Hire, deny, expiry and binding entries use the same tables (A4). |
| R3 | Fail closed: an unknown action, tool, right, plugin kind or role field refuses. | Role keys, actions and tools (`role.mjs:190,42,24-29`). Rights (`org-rights.mjs:122-129`). Plugin kinds (`packages/plugins/src/kinds.mjs:147-148`). | Inline roles and plugin-declared tools and actions have no check yet. P1 and PL1. |
| R4 | Hard waits stay with the human. A role can never hold an action outside `ROUTED`. | `vocabulary.mjs:109`, `role.mjs:43`. This covers `git.push.protected`, `git.push.remote`, `host.sudo`, `credential.mint`, `spend`, `deploy` and `data.delete` (`vocabulary.mjs:22,26,29,49,50,51,55`). | Added: the brief's list did not name it, and "any capacity" must not dissolve it. P1 keeps it for every composition. |
| R5 | Human-only acts: approving a hire, fire or role change (`org.hire`), binding a credential, changing personality after hire, installing or enabling a plugin, changing a rail, and resolving a merge or policy decision. No agent approves its own request, and no agent approves a request that changes authority. | No agent resolves its own arbitrated raise: it goes to the human (`broker.mjs:7075`). Plugin enabling needs a human session (`broker.mjs:6008`). Hard waits always go to the human (R4). | Corrected. The brief's "no agent approves another agent's request" is false today. A cross-role raise goes to the domain arbiter, the chief by default, and an agent of that role resolves it (`broker.mjs:7074,7212-7215`, `packages/organization/src/org-template.json:5`). PRD 1.5 REQ-DEC-2 put `git.merge.protected` and `policy.change` on that path (`vocabulary.mjs:69-70,83,93`), and merge-gate holds merge cross-role (`roles/merge-gate.json:10`, `broker.mjs:5198-5201`). This design keeps agent arbitration only for one-off routed work that grants no lasting authority: task, review, record and message actions. A4 routes every decision whose action is `role.hire`, `role.fire`, `credential.mint`, `git.merge.protected` or `policy.change` to the human, whatever its class. That reverses part of REQ-DEC-2, so it needs Jason's approval. The dev tester may enable some plugin kinds (`broker.mjs:6013`); that stays dev-only. Personality has no field yet (T1). |
| R6 | Every agent runs sandboxed as the instance uid, with only the tools its role names. A tool exists only if core or an installed, enabled plugin declares it. | bwrap with user, pid and ipc namespaces and a read-only root (`packages/seat/src/sandbox.mjs:76,176`). Tools are a closed core list (`vocabulary.mjs:142`) and the typed bus tools are fixed in core (`packages/harness/src/tools.mjs:96`). | Corrected: no plugin declares a tool today. PL1 adds plugin-declared tools. A sandbox escape holds the instance uid, which equals root for the instance (accounts design, "Host trust boundary"). |
| R7 | Organizations are isolated. Cross-organization reads go only through the assistant's Console read broker. | Each organization has its own bus and every non-assistant agent's scope is one organization (agent-levels design section 4). | The read broker is not built. It is A6. Until then nothing reads across organizations. |
| R8 | No grant beyond the granter, except through a flagged line in a human-approved decision. | People: `canGrant` subset (`org-rights.mjs:122-129`). Agents: a hire is bounded by `HIRE_ALLOWED` and the chief's list (`broker.mjs:6303-6305`). | A4 replaces that bound with the approval itself. The broker computes the flagged lines; the chief cannot omit them. |
| R9 | No hire chains. Only the chief raises hires, and a hired agent cannot hire. | `role-not-chief` (`broker.mjs:6193`). `role.hire` is not on `HIRE_ALLOWED` (`vocabulary.mjs:114-121`). | Added from ruling 8. P1 refuses `role.hire` and `role.fire` in any hired composition, so the rail survives `HIRE_ALLOWED` going away. |
| R10 | Plugin code runs with the host's authority, so installing one is an instance-level human act. | The loader reads only its own tree and takes no path from config (`packages/plugins/src/loader.mjs:1-5`). | Added: the loader says a module "runs with the host's authority" (`loader.mjs:5`). Install from GitHub or a zip has to keep that visible. No hash pin exists. PL1. |
| R11 | System config is written only by bootstrap. | `scripts/bootstrap.sh:5`, AGENTS.md invariant 2. | None. Nothing in this design writes system config. |
Dropped: none. Two items changed meaning. R5 now says "no agent approves its
own request" and lists what always goes to a human, because the code routes
cross-role work to an agent arbiter by design. R6 now says "core or a plugin"
declares a tool, because core declares all of them today.
## 4. The primitives
A role is a composition of these parts and nothing else.
**Core set.** Shipped in `vocabulary.mjs`, versioned, changed only by reviewed
commit:
- `tools`: core built-ins and `web` (`vocabulary.mjs:142`).
- `network`: one of `NETWORKS`.
- `authority.withinRole` and `authority.crossRole`: actions from `ROUTED`
(`vocabulary.mjs:73-102`).
- `credentials`: a known service with grantable scopes. Gitea `admin` and
`all` are never granted (`GITEA_SCOPE_CATEGORIES`).
- `messaging.sendTo` and `messaging.receiveFrom`.
- `skills`.
- `instructions`: the contract text, today the `.md` file.
- `personality`: new. Bounded text fields (summary, traits, voice), no
authority. Ruling 5 and ruling 12.
**Plugin-declared set.** From an enabled plugin's manifest (section 7):
tools, actions and credential services. A plugin action is always routed. A
plugin cannot declare a hard wait, cannot reuse a core name, and every name
carries the plugin's prefix (`<plugin>.<name>`).
**The validator.** P1 adds `checkComposition(role, registry, chief)`. It
replaces `HIRE_ALLOWED` and `unhireableAction` at hire time. It:
1. refuses any unknown key, as `role.mjs:190` does;
2. refuses any action, tool, network, service or scope the registry lacks,
where the registry is the core set plus the plugins enabled in that
organization;
3. refuses any action outside `ROUTED` or a plugin's routed set (R4);
4. refuses `role.hire` and `role.fire` (R9);
5. allows `git.merge.protected` and `policy.change` only in `crossRole`, so
each use is its own decision that a human resolves (R5);
6. refuses any string that fails `assertClean` (R1), and any field over its
length bound;
7. returns the flagged lines: every action, tool, network, credential scope
and messaging target the chief's own resolved role does not hold (R8).
The validator accepts every composition that passes. There is no allowlist of
roles and no list of hireable definitions.
**What "any role with any capacity" means.** The chief can propose any name,
any mix of registered tools and routed actions, any credential scope a service
grants, any personality, any number of agents. A human approves each one.
**What it cannot mean.** A hired agent cannot hold a hard wait, hire, approve
a human decision, read another organization, name a tool nothing declares,
carry a secret in its text, or run outside the sandbox. A new capability that
needs new enforcement, for example a new kind of external write, needs a
plugin that declares and enforces it. A name with no code behind it gives an
agent nothing.
## 5. Templates and organization roles
**Templates.** The shipped files in `roles/` become templates. They stay
there, versioned by reviewed commit. A template's identity is its name plus
the sha256 of its JSON and contract together, so no new field goes into
`roles/`. Plugins may ship templates too (section 7).
**Organization roles.** An organization's own definitions live in its file
under a new `definitions` key, one entry per name:
```json
"definitions": {
"release-writer": {
"rev": 3,
"from": { "template": "coder", "sha256": "<64 hex>" },
"role": { "tools": ["read", "edit"], "authority": { "withinRole": ["record.write"] } },
"instructions": "...",
"personality": { "summary": "...", "traits": ["..."], "voice": "..." }
}
}
```
An instance names `definition: "org:release-writer"` instead of a template
name. Project agents use the organization's definitions. Root agents keep
their own `definitions` in `<configDir>/agents.json`.
- **Import** copies a template into `definitions` with `from` set. The copy is
frozen: nothing changes it except an approved change.
- **Customize** edits the copy. **Invent** writes a definition with no `from`.
Both pass `checkComposition` before they are written.
- **Who writes.** A human holding `org.hire` drafts or edits a definition in
the Console. A draft no agent uses does nothing. A change to a definition a
hired agent uses takes effect only through a change decision (section 6).
The chief proposes a definition only inside a decision.
- **Template changes.** When a template's current hash differs from a copy's
`from.sha256`, the Console shows a diff and offers apply or keep. Apply is a
change decision (section 6). Nothing applies by itself (ruling 11).
- **Versioning.** `rev` counts up on every write. Every hire and change
decision records the definition's `rev` and hash.
- **A running agent.** Its sandbox, tools and contract are fixed at launch
from the bundle. A change that narrows authority takes effect at the broker
on the next call. Anything else takes effect at the next launch, and the
Console shows the agent as running an older `rev`.
- **Creative templates** are templates like the others. They still get no
host grant (`broker.mjs:2415`).
## 6. Hire as a decision
One decision action covers hire, fire and change, at every level.
**Fields.** The chief supplies:
| Field | Meaning |
|---|---|
| `op` | `hire`, `fire` or `change` |
| `level` | `org`, `project` (with the project name) or `root` |
| `name` | the agent's instance name, unused, not kept or retired |
| `role` | `{ template, sha256 }`, `{ definition, rev }` or `{ inline }` |
| `credentials` | each service and scope the agent needs |
| `personality` | the chief's draft |
| `route` | a proposed harness, model and thinking level |
| `reason` | 1 to 500 characters |
The broker adds, and the chief cannot set or edit:
- `flagged`: the lines `checkComposition` returns above the chief's own role;
- `credentialLines`: one line per service and scope;
- `digest`: a hash over the whole decision, as the Console resolution path
already checks (`broker.mjs:7200-7204`);
- `expiresAt`: seven days.
**Raise.** Among agents, only the instance in `launch.by` may raise one, as
today (`broker.mjs:6193`). The decision always routes to the human, whatever its
class, so the arbiter path at `broker.mjs:7074` never applies. A hire needs no
approval count and has no cap (ruling 3). Spend stays bounded by the
organization's limits.
**Approve.** A human whose membership in that organization holds `org.hire`
approves in the Console, against the current digest. Two limits in the MVP:
- a decision with any flagged line or any credential line needs the owner.
This mirrors `decide` stopping short of credential mints (accounts design,
section 3 "Rights"). A member with `org.hire` approves plain hires.
- a root-level decision needs the instance owner, since the root file covers
every organization (agent-levels section 7).
The human may change the route, narrow the role or drop credential lines
before approving. Widening means a new decision.
**What approval writes.** One writer call, with the human as actor and the
decision id in the context:
- org level: the organization writer, extended from `prepareHire`
(`instance-settings.mjs:354`). It writes the definition, the instance with
its route, and `launch.instances`;
- project level: the A2 project-agent writer, behind the projects fence;
- root level: the A2 root writer.
Credential bindings then go through the existing human-only credential path
(`broker.mjs:6144`). Until every binding succeeds, `launch` refuses the agent
with `credential-unbound` and names the missing one, as A3 does for unbound
copies. Because approval sets the route, launch no longer refuses `no-route`
for an approved hire.
**Deny, expire, withdraw.** Deny and expiry write nothing but the trail entry.
The chief may withdraw its own decision (`broker.mjs:7205-7211`).
**Fire and change** are the same decision with `op` set. Fire revokes and
delists as today (`instance-settings.mjs:396`).
**A person starting a hire.** A human holding `org.hire` may start a hire,
fire or change in the Console. It is still a decision, with the same fields,
broker-computed lines, digest and trail, raised and resolved in one step by
that person. Flagged and credential lines still need the owner. I read ruling
8 as limiting which agent raises hires, not as stopping a person from hiring.
**Trail.** Raise, each resolution, the writer receipt and each binding are
events in the write-once tables (R2).
**What this replaces.** The direct `instance.hire` and `instance.fire` paths
(`broker.mjs:6185-6280`) and the `hireable` check (`broker.mjs:6291-6306`) go
away. The chief's `hire` tool (`packages/harness/src/tools.mjs:225-226`)
raises the decision instead of writing. Agent-levels section 7 had three
paths, one per level. Now there is one decision with a `level` field, so A4
and A5 become one slice, with the root level waiting for A2's root writer.
## 7. Plugins and modules
**Today.** Plugin kinds are a closed core list (`kinds.mjs:4-5,22-105`). The
loader reads only `packages/plugins/<kind>/<name>/index.mjs` in its own tree,
checks symlinks, exports and interface version, and pins no hash
(`loader.mjs:1-5,41-72`). A refused module keeps the kind's last good set
(`loader.mjs:7-9`). Enabling is `plugins.set` under `org.setup`
(`org-rights.mjs:74`), owner-only.
**Manifest.** Each plugin gets a `plugin.json`: name, version, kinds,
interface versions, a sha256 over its files, and what it declares: tools
(name, description, argument schema, whether it runs in the sandbox or on the
host), routed actions, credential services, and role templates.
**Install.** Sources are a GitHub URL at a commit, a zip, or a local
directory. The instance owner installs at the host with the expected hash.
The install refuses a hash mismatch, a core name, a hard-wait action or an
unknown kind. Installed plugins live read-only in an instance-level plugin
root, listed with their hashes in a lock file. That reads a path outside the
loader's own tree, which changes lead decision 143 (`loader.mjs:3-5`). Jason
decides that in PL1.
**Pin and load.** Every load checks the hash in the lock file. A mismatch
refuses the plugin and logs it. The loader's existing refusal handling keeps
core running.
**Enable.** Per organization, by the owner, through `plugins.set`. Install is
instance-wide because the code runs as the instance uid for every
organization (R10).
**A disabled or missing plugin.** Core keeps running. An agent whose role
names the plugin's tools or actions refuses at launch with
`plugin-disabled` and the plugin's name. A running agent's calls to those
tools refuse with the same code. Nothing is fired or rewritten.
**Preconfigured systems** such as a software factory or a writing workshop
are plugins that ship templates, a system type and tools. Installing one hires
nobody. The chief still raises a decision for each agent.
**Marketplace.** Out of scope. The design must not preclude one, so the
manifest keeps the source URL, version and hash, and reserves a `signature`
field. The install screen already lists every tool that runs on the host.
## 8. The minimal base
A new organization already starts with the human, a chief and an assistant
(`org-template.json:4,6`). That matches ruling 9. A6 later moves the
assistant to the root level (agent-levels section 9).
The chief reads the user's plan and raises one hire decision per agent it
needs, each citing the plan in its reason. It starts with the fewest agents
and adds one when a task needs a role nobody holds. Separate concerns go in
separate organizations or projects.
Every other shipped organization role becomes a template only: coder, pm,
reviewer, security-reviewer, merge-gate, cto, the five chiefs (executive,
financial, information, information security, marketing) and the six creative
roles (companion, continuity, fact-checker, ideas, proofreader, research).
`researcher.json` is a version 1 worker role (`roles/researcher.json:2`), which
a hire refuses (`broker.mjs:6303`), and `conductor-policy.json` is not a role.
Neither changes.
## 9. Usability and security
Read this first. It says who can change what.
Everything in the first two columns is done by a person. No agent can do any
of it.
| A person changes it directly | It needs an approved decision | Only a reviewed code change |
|---|---|---|
| Organizations and projects (owner) | Hiring, firing or changing an agent's role (`org.hire`) | The rails in section 3 |
| Drafting a role copy, or inventing a role from known parts. A draft does nothing until a decision uses it (`org.hire`) | Any agent power above the chief's own, shown flagged (owner approves) | The core list of tools and actions |
| An agent's model, limits and route (`org.hire`) | Giving an agent a password, key or token (owner approves) | The shipped role templates |
| An agent's personality and traits after hire | Applying a template update to your copy | The rights table, including `org.hire` |
| Enabling a plugin in an organization (owner) | Each merge or policy change an agent asks for | Plugin kinds |
| Installing a plugin on the instance (instance owner, at the host) | Adding an agent at the root level (instance owner) | System config, which only bootstrap writes |
What never moves, whoever asks:
- An agent cannot push to a protected branch, push to a remote, deploy, spend,
mint a credential, delete data or use sudo by itself. A person decides each
of those every time.
- An agent cannot hire. Only the chief asks, and a person says yes.
- An agent cannot merge into a protected branch or change policy unless a
person approves that one merge or change.
- An agent cannot see another organization's work. The assistant's read
across organizations is planned (A6) and not built yet.
- A plugin runs with the same power as the Mosaic service itself. Install
only code you trust.
## 10. Threat model
| Threat | What stops it | What is left |
|---|---|---|
| A prompt-injected chief proposes a hostile role | The broker computes the flagged and credential lines; the chief cannot hide them. Hard waits, hiring and unknown parts refuse (R4, R9, R3). A human approves against the digest. | A human can approve a hostile role. Flagged and credential lines need the owner. |
| A custom role names an unknown tool | `checkComposition` refuses it at the decision and at the writer. Launch refuses it again (R3). | None. |
| A plugin from an untrusted source | Instance owner only; hash pin; the install screen lists host-run tools. | Approved plugin code runs as the instance uid (R10). A pin does not make code safe. |
| A credential scope wider than the task | Each scope is its own line; owner-only approval; the approver may drop lines; admin and all never granted. | The service's own scope granularity. |
| An arbiter agent approves another agent's merge or policy change | A4 routes those decisions to the human whatever their class (R5). | Other routed work, such as task scope, still has an agent arbiter by design. |
| Approval fatigue on flagged grants | Flagged lines shown apart from plain ones; credential and flagged decisions go to the owner; no batch approval; seven-day expiry. | A busy owner who approves without reading. A later slice could limit how many flagged decisions one approval screen holds. |
| An imported template drifts from its source | The copy is frozen with its source hash. A changed template produces a diff offer, never a silent change. | An owner who keeps an old copy keeps its old behaviour. That is the ruling. |
## 11. Invariant 7 amendment
Proposed only. Jason approves it through a reviewed commit.
Replace AGENTS.md invariant 7 (`AGENTS.md:45-47`) with:
> 7. **Policy**: missions govern tasks; a task narrows, never widens. The
> rails, the core vocabulary of tools and actions, and the shipped role
> templates in `roles/` change only by reviewed commit. An organization's
> own roles are copies or compositions of known parts. One takes effect
> only through a decision a human with `org.hire` approves, which lists
> every grant above the chief's own as a flagged line. Only the chief
> raises hires, and a hired agent cannot hire.
Touched role-model lines:
- `AGENTS.md:107`: keep "policy/role expansion" as a protected act, and add
"an approved hire decision is that authority for the one agent it names".
- `AGENTS.md:209`: change to "`roles/`: shipped role templates and the
conductor policy".
## 12. Slices
Phase A after A3, in order. A6 and A7 stay as agent-levels section 9 has
them.
**R1. The `org.hire` right.** Add `'org.hire': flag(true)` to `RIGHTS` and a
verb entry for the hire resolution in `VERB_RIGHTS`. The owner bundle picks it
up from `Object.keys(RIGHTS)` (`org-rights.mjs:22`); member, collaborator and
approver do not get it. Rights policy, so Jason's approval
(accounts design, `Grant needed` at line 1391). Tests: the owner has it; the
three templates lack it; `canGrant` lets an owner grant it to a member; an
unknown right still refuses.
**P1. Primitives validator.** `checkComposition` and the registry, beside
`role.mjs`. `HIRE_ALLOWED` stays until A4 switches over. It changes no role
file but it defines authority policy, so Jason's approval. Tests: an unknown
key, action, tool, service or scope refuses; each hard wait refuses;
`role.hire` and `role.fire` refuse; a secret in instructions or personality
refuses; `git.merge.protected` or `policy.change` in `withinRole` refuses;
flagged lines match a hand-built expected set for coder against the
shipped chief.
**T1. Organization definitions and templates.** The `definitions` key,
`org:` references, import, the human edit verbs under `org.hire`, the
personality fields, the template hash and the diff read. Tests: import pins
the hash; editing a template file changes no copy; the diff read shows the
change; an `org:` reference to a missing definition refuses at launch; an
agent cannot call the edit verbs.
**A4. Hire as a decision, all levels.** The decision action, raise, Console
approval, the writer calls, credential lines, expiry, fire and change, the
Console path that raises and resolves in one step, human routing for
`role.hire`, `role.fire`, `credential.mint`, `git.merge.protected` and
`policy.change` decisions (R5), and removal of the direct path and
`#hireable`. Org and project land first; root
waits for A2's root writer. This edits `roles/chief-of-staff.json` and `.md`
(drops `hireable`, rewords hire), the vocabulary and AGENTS.md invariant 7,
and reverses part of PRD 1.5 REQ-DEC-2, so Jason's approval. Tests: a hired agent's raise refuses `role-not-chief`; a
chief decision cannot carry its own `flagged`; a member with `org.hire`
approving a credential or flagged decision refuses; a stale digest refuses;
deny and expiry write nothing; approval writes the role, the route and the
trail, and launch refuses `credential-unbound` until binding; fire revokes;
the old `instance.hire` verb refuses; a cross-role `git.merge.protected` or
`policy.change` decision routes to the human and an arbiter's resolve refuses
`not-resolver`; a hire a person starts in the Console leaves a decision record
with its flagged lines.
**PL1. Plugin manifest, install and declared tools.** Manifest, pinned
install root, lock file, the registry merge, and `plugin-disabled` refusals.
Changes lead decision 143 and the loader's trust rule, so Jason's approval.
Tests: a hash mismatch refuses and core keeps running; a manifest naming a
core tool or a hard wait refuses; disabling a plugin makes its agents refuse
at launch with its name; a running agent's call to a disabled tool refuses.
@@ -0,0 +1,212 @@
# System-type toolsets at launch
Status: design only. Nothing here is built and no `roles/` or contract file is
changed. Prepared 2026-11-10 against `refactor` `8630b8dfc` for phase P2 of
#1873 (unified workspace). PRD requirement: REQ-SYS-2, "An agent working in a
project gets the toolsets of the system types that project uses."
## What exists today
- **Modules.** `installedSystemTypes()` (`packages/plugins/src/system-types.mjs`)
loads every installed `system-type` module and returns
`{id, label, description, toolsets}`. It is fail-closed: a refused module, a
missing or extra key, or a toolset name that fails `NAME` throws
`system-type-invalid`. All five shipped modules (business-management, coding,
creative-writing, marketing, montage) declare `toolsets: []`.
- **Organization enable.** The organization file's
`plugins['system-type']` holds the enabled ids. `plugins.set` writes it
through the organization writer behind the owner fence
(`organization-system-types-human-only`) and returns a transaction receipt.
The bus reads the current set with `system-types.current` on every read and
write, so it never trusts a stale snapshot.
- **Project selection.** `project.system-types.set` appends a row to
`project_system_types` (bus store, `seq`-ordered, latest wins) and accepts
only enabled ids (`system-type-not-enabled`). A read returns
`{selected, active, inactive}`, where `active = selected ∩ enabled` and
`inactive = selected − enabled`.
- **Launch.** `resolveInstance` (`packages/organization/src/resolve.mjs`)
starts from the role definition's `tools` and `network`, narrows them by
each layer's `limits.tools` and `limits.network` (system, business,
project, agent), and freezes the result with a digest. The role's `tools`
is therefore a ceiling: a launch gets those tools minus whatever a layer
limit removes, never more. The launcher (`packages/cli/src/launcher.mjs`) passes that
result to `buildBundle` (`packages/harness/src/bundle.mjs`), which writes
`policy.json` (`tools: builtinTools(resolved.limits.tools)`), `tools.json`
(typed host tools) and `manifest.json` once each (`flag: "wx"`). Typed
host tools (`typedTools`, `packages/harness/src/tools.mjs`) are not granted
by tool name: each is built only when the resolved `authority` holds the
action it needs.
- **Run tasks.** `scripts/mosaic-task.mjs` validates a mission's
`capabilities.tools` as a ceiling and a task's `capabilities.tools` as the
allowlist, both drawn from `SUPPORTED_TOOLS`. A task narrows the mission.
It never widens it.
Nothing on the launch path reads system types today, so every launch gets
its role's tools as narrowed by the layer limits. That is the coding
module's behavior, and the design keeps it as the default.
## Resolution at launch
A launch resolves its toolsets in the host-side launcher, before
`resolveInstance` freezes the digest. In order:
1. **Pick the project.** The launch's project is the one the session is
started for: the thread's project for a project thread, or none. A launch
with no single project (an organization-wide session, or a role started
with several code projects in scope) gets no system-type toolsets. Taking
the union across projects would widen one project's agent with another
project's tools, so the union is ruled out.
2. **Read the active set.** The launcher asks the bus, as the host, for the
project's selection and computes `active = selected ∩ enabled`. `enabled`
comes from `system-types.current`, the same trusted read the routes use.
Inactive selections are dropped and recorded in the manifest. They do not
refuse the launch, because the owner may disable a type at any time and
that must not strand running work.
3. **Load the modules.** `installedSystemTypes()` runs. If it throws
`system-type-invalid`, the launch refuses with that code. An active id
with no installed module refuses `system-type-missing`.
4. **Map toolset names to tools.** Each active module's `toolsets` are looked
up in the reviewed mapping file (below). The result is a set of tool names,
each a member of the role vocabulary `TOOLS`
(`packages/organization/src/vocabulary.mjs`). Typed host tools cannot be
named: they stay gated by the role's `authority` actions exactly as today,
and a system type never changes `authority`.
5. **Bound by the ceilings.** The role's `tools` stays the hard ceiling for
every effective tool, including any a system type switches on. A role may
hold some of its tools back for typed projects by listing them in
`systemTypeTools` (new, see the proposal), which must be a subset of the
role's `tools`. Then:
- `base` = (role `tools` − `systemTypeTools`), narrowed by every layer's
`limits.tools`, as `resolveInstance` does today;
- `added` = mapped tools ∩ `systemTypeTools`, narrowed by every layer's
`limits.tools` the same way;
- `effective` = `base ∪ added`;
- for a run task, `effective` is then intersected with the mission's
`capabilities.tools` and then the task's. That intersection covers
base tools too, as it does today.
Every term is a subset of role `tools` ∩ every layer limit, so a system
type can only switch on a tool the role already holds and the layers
already allow. Invariant 7 holds because each later step can only remove
entries. A role without `systemTypeTools` behaves exactly as today.
6. **Record it.** `resolveInstance` takes the added list as an input and
includes `systemTypes: {project, active, dropped, toolsets, added}` in the
resolved object, so the digest covers it. `buildBundle` writes the same
block into `manifest.json`. The run record then shows which types and
mapping produced each tool.
No step adds credentials, network reach or authority. `limits.network` and
`authority` are computed exactly as today. A toolset that needs a credential
gets it the way any role does, through the role file's `credentials` and a
reviewed commit. A system type never grants one.
## The mapping file
The mapping is repository policy, so it lives under `roles/` and changes only
through a reviewed commit, the same rule that governs `roles/*.json`
(invariant 7, AGENTS.md "Role model"). Proposed path:
`roles/toolsets/system-types.json`. It sits in a subdirectory because two
loaders read every `roles/*.json` as a role definition
(`instance-settings.mjs` `definitions()`, `harness/tests/tools.test.mjs`).
A mapping file at `roles/` top level would be read as a broken role.
Proposed format:
```json
{
"toolsetsVersion": 1,
"toolsets": {
"repository": { "tools": ["read", "write", "edit", "bash", "grep", "find", "ls"] },
"web-research": { "tools": ["web"] }
}
}
```
The two entries are illustrations, not part of the proposal. No shipped
module names a toolset today, so the file would ship as
`{"toolsetsVersion": 1, "toolsets": {}}`.
Rules a loader enforces, fail-closed:
- Exactly the keys `toolsetsVersion` (must be `1`) and `toolsets`. Each entry
has exactly the key `tools`.
- Each toolset name matches `NAME` from `packages/plugins/src/kinds.mjs`, the
same pattern modules must use.
- `tools` is a non-empty list of distinct names, each in `TOOLS`. A typed
host tool name, or anything else, refuses `toolset-mapping-invalid` at
load.
- The file is read from the release copy, like the role files, and its
sha256 goes into `manifest.sources`.
Who may change it: a reviewed commit on the repository, landed by the lead
with Jason's authority over role policy. An organization owner cannot change
it at runtime, and neither the Console nor the bus has a write path to it.
Organization owners choose which types are enabled and projects choose among
them. The repository decides what a type's toolsets may contain.
## Fail-closed behavior
| Condition | Result |
| --- | --- |
| An installed module is refused or malformed | launch refuses `system-type-invalid` |
| An active id has no installed module | launch refuses `system-type-missing` |
| A module names a toolset the mapping lacks | launch refuses `toolset-unmapped` |
| The mapping file is missing, unreadable or invalid | launch refuses `toolset-mapping-invalid`, but only when at least one active type declares a toolset; a launch with nothing to map never reads it |
| A mapped tool is outside the role's `systemTypeTools` (and so not switched on) | the tool is dropped and recorded under `dropped`, launch continues |
| The bus cannot answer the project's selection | launch refuses `system-types-unavailable`; it does not fall back to "no types", which could hide a missing toolset the operator expects |
| A selection is inactive (disabled in the organization) | dropped and recorded, launch continues |
The two "continue" rows are narrowing, never widening. Every row that could
change what an agent may do refuses instead.
## The coding module
Coding declares `toolsets: []`. It adds nothing, and a coding project's agent
keeps its role's tools as the layer limits narrow them: for
`roles/coder.json` the ceiling is `read, write, edit, bash, grep, find, ls`
with network `api-only`, the same as today. Turning the design on changes nothing for any shipped module until a
module declares a toolset and a reviewed commit maps it.
## Proposed `roles/` change (not applied)
1. Add an optional V2 key `systemTypeTools` to the role schema
(`packages/organization/src/role.mjs`): a distinct list from `TOOLS` (same
check as `checkTools`, but empty is allowed) that must be a subset of the
role's own `tools`, or the role file refuses at load. It names the tools
the role holds back unless an active system type maps them. Absent means
`[]`: nothing is held back and no system type changes that role's tools.
Every existing role file stays valid and behaves as today, and
`checkCreative` keeps inspecting `tools`, which still bounds everything.
```diff
const V2_REQUIRED = ["roleVersion", "name", "title", "contract", "tools", "network", "authority", "credentials"];
-const V2_KEYS = [...V2_REQUIRED, "messaging", "skills", "hireable"];
+const V2_KEYS = [...V2_REQUIRED, "messaging", "skills", "hireable", "systemTypeTools"];
```
2. Add `roles/toolsets/system-types.json` with an empty `toolsets` object, and
a loader beside the role loader with the checks listed above.
3. Have `resolveInstance` accept `systemTypes` (the launcher's step 1-4
result) and apply step 5. Have `buildBundle` copy the block into the
manifest. `scripts/mosaic-task.mjs` already bounds tools by mission and
task. It would apply that filter to the final effective set.
No contract under `contracts/` changes: the constitution's least-privilege
rule already covers this. The design adds no grant path: the effective set
stays within role `tools` and every layer limit, and `authority`, network and
credentials are untouched. What it does add is a runtime input (the
project's active system types) that can switch a held-back tool on, so a
role author who lists a tool in `systemTypeTools` is deciding that an
organization owner's enable plus a project's selection may turn it on. Each of the
three steps is a reviewed change to role policy and needs Jason's sign-off
before it is built.
## Open points for the build
- Business-file projects (`b.projects`, used for checkouts) and bus projects
(UUIDs in `project_system_types`) are separate records today. Step 1 needs
the launch to carry the bus project id of the thread it serves. The build
should confirm how a project thread names its project at launch before
wiring step 2.
- A running session keeps the toolsets it launched with. Disabling a type
takes effect at the next launch, which matches how role changes behave.
+2
View File
@@ -0,0 +1,2 @@
// Old path of packages/organization/src/accounts.mjs (rename design r2, stage B).
export * from "../../organization/src/accounts.mjs";
+29
View File
@@ -1298,3 +1298,32 @@ shows selected, active and inactive ids. The owner replaces the selection with
`mosaic project system-types-set PROJECT --types coding,montage --org ID`; an
empty `--types ''` clears it. Disabled selections stay recorded but become
inactive. Project settings changes are append-only and require human proof.
## Accounts
`mosaic accounts` administers the instance-level accounts store locally, as the
effective uid owning the config directory. It requires a human shell, rejects `--tester`,
and takes no business selector or bus connection. It has no organization rights
verbs. Console sign-in, sessions and membership evaluation are later Phase M
slices; these commands alone do not turn accounts mode on.
```sh
mosaic accounts init --name Operator
mosaic accounts list --json
mosaic accounts user add --name Pat --subject pocket:verified-subject --revision 1
mosaic accounts user disable u-0123456789ab --revision 2
mosaic accounts user enable u-0123456789ab --revision 3
mosaic accounts user signout u-0123456789ab --revision 4
mosaic accounts user remove u-0123456789ab --revision 5
```
Init creates one operator account containing every configured `console.sso`
subject and refuses if accounts.json already exists. Add mints its id and defaults
to instance role `user`; `--role operator` creates another operator. Writes require
the file revision from list. Disable, enable and signout advance `authGen`, and
the last active operator cannot be disabled or removed. Removal scans ownership
while holding the registry then accounts locks, refuses if the account owns an
organization (including archived organizations) or any registry entry lacks its
owner metadata during creation, and removes its memberships in
the same write. List reports rollback rows needing repair; its JSON omits invite
bindings and hashes. Receipts record effective process uid and verb and omit subjects and secrets.
+109
View File
@@ -0,0 +1,109 @@
// Phase M2: the local, instance-uid account administration exception. No org
// rights commands and no bus transport: account init must work before orgs exist.
import { basename, join } from 'node:path';
import { configDir } from '../../organization/src/organization-config.mjs';
import { accountsRepairs, assertAccountsOwner, loadAccounts, mintAccountId, withAccounts } from '../../organization/src/accounts.mjs';
import { withRegistry, readRegistryLocked } from '../../organization/src/org-registry.mjs';
import { readJsonFile, rejectUnknownKeys, requireString } from '../../organization/src/util.mjs';
import { CliError } from './errors.mjs';
import { refuseInsideAgent } from './transport.mjs';
export const ACCOUNTS_USAGE = [
'mosaic accounts init [--name NAME]',
'mosaic accounts list [--json]',
'mosaic accounts user add --name NAME --subject PROVIDER:SUB [--role operator|user] --revision N',
'mosaic accounts user disable|enable|signout|remove ID --revision N',
].join('\n');
function parse(args) {
const words = [], values = {}, flags = new Set();
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (!arg.startsWith('--')) { words.push(arg); continue; }
if (flags.has(arg) || Object.hasOwn(values, arg)) throw new CliError(`duplicate ${arg}`, 4);
if (arg === '--tester') throw new CliError('refused: accounts takes the human shell, not --tester', 3);
if (arg === '--json') flags.add(arg);
else if (['--name', '--subject', '--role', '--revision'].includes(arg) && args[i + 1] && !args[i + 1].startsWith('--')) values[arg] = args[++i];
else throw new CliError(ACCOUNTS_USAGE, 4);
}
const verb = words[0] === 'user' ? `user.${words[1]}` : words[0];
const valid = ['init', 'list'].includes(verb) ? words.length === 1 : verb === 'user.add' ? words.length === 2 : ['user.disable', 'user.enable', 'user.signout', 'user.remove'].includes(verb) && words.length === 3;
if (!valid) throw new CliError(ACCOUNTS_USAGE, 4);
const allowed = verb === 'init' ? ['--name'] : verb === 'list' ? [] : verb === 'user.add' ? ['--name', '--subject', '--role', '--revision'] : ['--revision'];
if (Object.keys(values).some(k => !allowed.includes(k)) || (flags.size && verb !== 'list')) throw new CliError(ACCOUNTS_USAGE, 4);
const revision = verb === 'init' ? 0 : Number(values['--revision']);
if (verb !== 'list' && verb !== 'init' && (!/^[1-9][0-9]*$/.test(values['--revision'] ?? '') || !Number.isSafeInteger(revision))) throw new CliError('--revision requires the revision from accounts list', 4);
return { verb, values, flags, revision, id: words[2] };
}
function owns(user, owner, providers) {
return user.subjects.some(s => s.provider === owner.provider && s.sub === owner.sub && providers.find(p => p.id === s.provider)?.issuer === owner.iss);
}
function readOwner(root) {
const owner = readJsonFile(join(root, 'owner.json'), 'organization owner', st => {
if (st.uid !== process.geteuid() || (st.mode & 0o777) !== 0o600) throw new CliError('refused: unsafe organization owner', 3);
});
rejectUnknownKeys(owner, ['provider', 'iss', 'sub'], 'organization owner');
for (const key of ['provider', 'iss', 'sub']) requireString(owner[key], `owner.${key}`, { max: 2048 });
return owner;
}
export async function accountsCommand(args, io, deps) {
refuseInsideAgent(io.env);
try {
const parsed = parse(args);
const dir = deps.configDir ?? configDir(io.env);
// This is deliberately before withRegistry as well as withAccounts.
assertAccountsOwner(dir, { uid: deps.uid ?? process.geteuid() });
const providers = deps.system.console?.sso?.providers ?? [];
const ctx = { configDir: dir, instance: deps.instance ?? basename(dir).replace(/^mosaic-/, ''), providers };
if (parsed.verb === 'list') {
const doc = loadAccounts(join(dir, 'accounts.json'), ctx);
const repairs = accountsRepairs(doc);
// Invites are not administered here. Never expose bindings or token hashes.
const listed = { ...doc, invites: doc.invites.map(({ tokenHash, bind, ...invite }) => invite), repairs };
io.stdout.write(parsed.flags.has('--json') ? JSON.stringify(listed, null, 2) + '\n' : [`accounts revision ${doc.revision}`, ...doc.users.map(u => `${u.id} ${u.name} ${u.instanceRole} ${u.state} authGen ${u.authGen}`), ...repairs.map(r => `${r.kind} ${r.org} needs repair: ${[r.unknownRole, ...r.unknownRights].filter(Boolean).join(', ')}`)].join('\n') + '\n');
return;
}
let added;
const mutate = doc => {
if (parsed.verb === 'init') {
const subjects = providers.flatMap(p => (p.subjects ?? []).map(s => ({ provider: p.id, sub: s.sub })));
if (!subjects.length) throw new CliError('refused: init requires console.sso subjects', 3);
added = { id: mintAccountId(), name: parsed.values['--name'] ?? 'Operator', subjects, instanceRole: 'operator', state: 'active', authGen: 1, created: new Date().toISOString() };
doc.users.push(added); return;
}
if (parsed.verb === 'user.add') {
const value = parsed.values['--subject'] ?? '', colon = value.indexOf(':');
if (!parsed.values['--name'] || colon < 1 || colon === value.length - 1) throw new CliError('user add needs --name and --subject PROVIDER:SUB', 4);
added = { id: mintAccountId(), name: parsed.values['--name'], subjects: [{ provider: value.slice(0, colon), sub: value.slice(colon + 1) }], instanceRole: parsed.values['--role'] ?? 'user', state: 'active', authGen: 1, created: new Date().toISOString() };
doc.users.push(added); return;
}
const u = doc.users.find(u => u.id === parsed.id);
if (!u) throw new CliError('refused: account not found', 2);
if (parsed.verb === 'user.remove') { doc.users = doc.users.filter(x => x !== u); doc.memberships = doc.memberships.filter(m => m.user !== u.id); }
else if (parsed.verb === 'user.signout') u.authGen++;
else { const state = parsed.verb === 'user.disable' ? 'disabled' : 'active'; if (u.state === state) throw new CliError('refused: account already has that state', 2); u.state = state; }
};
const opts = { revision: parsed.revision, verb: parsed.verb, init: parsed.verb === 'init' };
let result;
if (parsed.verb === 'user.remove') {
result = await withRegistry(ctx, registry => withAccounts(ctx, { ...opts, registry }, async doc => {
const user = doc.users.find(u => u.id === parsed.id);
if (!user) throw new CliError('refused: account not found', 2);
// Even disabled accounts keep ownership. A real create reserves before
// writing owner.json, so any missing owner must block removal too.
for (const entry of Object.values((await readRegistryLocked(registry)).orgs)) {
let owner;
try { owner = readOwner(entry.root); }
catch (e) { if (/not found/.test(e.message)) throw new CliError(`refused: org-create-in-progress: ${entry.id} has no owner metadata`, 3); throw e; }
if (owns(user, owner, providers)) throw new CliError(`refused: user-owns-org: ${entry.id}`, 3);
}
mutate(doc);
}), { write: false });
} else result = await withAccounts(ctx, opts, mutate);
io.stdout.write(`accounts ${parsed.verb.replace('.', ' ')}: ${added?.id ?? parsed.id ?? ''}, revision ${result.revision}\n`);
} catch (e) {
if (e instanceof CliError) throw e;
const error = new CliError(`refused: ${e.code ? e.code + ': ' : ''}${e.message}${e.committed ? '; accounts write committed; durability or receipt step failed; inspect before retrying' : ''}`, 3);
if (e.committed) error.committed = true;
throw error;
}
}
+3
View File
@@ -24,6 +24,7 @@ import { hostsCommand, HOSTS_USAGE } from "./hosts.mjs";
import { grantsCommand, GRANTS_USAGE } from "./grants.mjs";
import { instancesCommand, orgInstancesCommand, ORG_INSTANCES_USAGE } from "./instances.mjs";
import { accessCommand, accessContextFor } from "./access.mjs";
import { accountsCommand, ACCOUNTS_USAGE } from "./accounts.mjs";
import { createChannelCaller } from "../../plugins/src/channel.mjs";
import { PluginError } from "../../plugins/src/kinds.mjs";
import { CliError, CliExit } from "./errors.mjs";
@@ -52,6 +53,7 @@ import { devCommand, DEV_USAGE, fixtureLaunchEnv, fixtureLauncherOptions } from
import { runCommand, RUN_USAGE } from "./run-evidence.mjs";
export const USAGE = `usage:
${ACCOUNTS_USAGE}
${ORG_USAGE}
${USAGE_USAGE}
${LIMITS_USAGE}
@@ -745,6 +747,7 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
now: deps.now,
});
}
if (verb === 'accounts') return accountsCommand(rest, io, { system: deps.system ?? loadSystem({ env: io.env }), configDir: deps.accountsConfigDir });
if (verb === "limits") return limitsCommand(rest,io,p=>callFor(p,io,deps));
if (verb === "usage") return usageCommand(rest, io, p => callFor(p, io, deps));
if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps));
+71
View File
@@ -0,0 +1,71 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, chmodSync, writeFileSync, readFileSync, mkdirSync, existsSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { accountsCommand } from '../src/accounts.mjs';
import { main } from '../src/cli.mjs';
import { withRegistry, reserveOrg } from '../../organization/src/org-registry.mjs';
const providers = [{ id: 'pocket', issuer: 'https://id.example', subjects: [{ sub: 'owner' }, { sub: 'other-owner' }] }, { id: 'second', issuer: 'https://second.example', subjects: [{ sub: 'owner' }] }];
function setup(t) {
const configDir = mkdtempSync(join(tmpdir(), 'a-')); chmodSync(configDir, 0o700); t.after(() => rmSync(configDir, { recursive: true, force: true }));
const deps = { configDir, instance: 'test', system: { console: { sso: { providers } } } }; let output = '';
const io = { env: {}, stdout: { write: s => output += s }, stderr: { write: () => {} } };
return { configDir, deps, io, run: args => accountsCommand(args, io, deps), read: () => JSON.parse(readFileSync(join(configDir, 'accounts.json'))), output: () => output };
}
test('init makes exactly one operator from all configured subjects, then refuses rerun', async t => {
const s = setup(t); await s.run(['init']); const d = s.read(); assert.equal(d.users.length, 1); assert.equal(d.users[0].instanceRole, 'operator'); assert.equal(d.users[0].subjects.length, 3); assert.match(d.users[0].id, /^u-[0-9a-f]{12}$/); assert.equal(d.revision, 1);
await assert.rejects(s.run(['init']), /already exists/); await s.run(['list', '--json']); assert.match(s.output(), /authGen/);
const r = JSON.parse(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8')); assert.equal(r.uid, process.getuid()); assert.equal(r.verb, 'init'); assert.ok(!JSON.stringify(r).includes('other-owner'));
});
test('CLI add, disable, enable, signout and remove; last operator guard and stale revisions', async t => {
const s = setup(t); await s.run(['init']); const op = s.read().users[0].id;
await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat:sub', '--revision', '1']); const id = s.read().users[1].id;
for (const [verb, revision, generation] of [['disable', 2, 2], ['enable', 3, 3], ['signout', 4, 4]]) { await s.run(['user', verb, id, '--revision', String(revision)]); assert.equal(s.read().users[1].authGen, generation); }
await assert.rejects(s.run(['user', 'disable', op, '--revision', '5']), /active operator/);
await assert.rejects(s.run(['user', 'signout', id, '--revision', '1']), /revision-conflict/);
await s.run(['user', 'remove', id, '--revision', '5']); assert.equal(s.read().users.length, 1); assert.equal(s.read().revision, 6);
assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8').trim().split('\n').length, 6);
});
test('host uid refusal happens before any lock, even on list or remove', async t => {
const s = setup(t); for (const args of [['list'], ['init'], ['user', 'remove', 'u-000000000001', '--revision', '1']]) {
await assert.rejects(accountsCommand(args, s.io, { ...s.deps, uid: process.getuid() + 1 }), /uid/);
assert.equal(existsSync(join(s.configDir, 'accounts.lock')), false); assert.equal(existsSync(join(s.configDir, 'org-runtime.lock')), false);
}
});
test('remove holds registry before accounts, refuses ownership of active or archived orgs, including a preceding create', async t => {
const s = setup(t); await s.run(['init']); await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat', '--revision', '1']); const id = s.read().users[1].id;
const root = join(s.configDir, 'acme'); mkdirSync(root, { mode: 0o700 });
let release, entered; const ready = new Promise(r => entered = r), hold = new Promise(r => release = r);
const create = withRegistry(s.deps, async registry => { entered(); await hold; registry.orgs.acme = { id: 'acme', root, state: 'archived', revision: 1, created: '2026-11-10T00:00:00Z' }; writeFileSync(join(root, 'owner.json'), JSON.stringify({ provider: 'pocket', iss: 'https://id.example', sub: 'pat' }), { mode: 0o600 }); });
await ready; const removing = s.run(['user', 'remove', id, '--revision', '2']); release(); await create; await assert.rejects(removing, /user-owns-org/); assert.equal(s.read().revision, 2);
});
test('remove drops memberships atomically, and malformed ownership metadata refuses', async t => {
const s = setup(t); await s.run(['init']); await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat', '--revision', '1']); const d = s.read(), id = d.users[1].id;
d.memberships.push({ org: 'acme', incarnation: 'a'.repeat(32), user: id, role: 'member', rights: ['org.view'], scope: { projects: ['*'], instances: ['*'] }, rev: 1, by: d.users[0].id, added: '2026-11-10T00:00:00Z', via: 'invite:abc' }); writeFileSync(join(s.configDir, 'accounts.json'), JSON.stringify(d));
await s.run(['user', 'remove', id, '--revision', '2']); assert.deepEqual(s.read().memberships, []);
const root = join(s.configDir, 'broken'); mkdirSync(root, { mode: 0o700 });
await withRegistry(s.deps, async registry => { registry.orgs.broken = { id: 'broken', root, state: 'active', revision: 1, created: 'now' }; writeFileSync(join(root, 'owner.json'), '{}', { mode: 0o600 }); });
await assert.rejects(s.run(['user', 'remove', s.read().users[0].id, '--revision', '3']), /owner/);
});
test('mosaic main wires accounts without a bus; tester, agent and unexpected args refuse', async t => {
const s = setup(t); await main(['accounts', 'init'], s.io, { system: s.deps.system, accountsConfigDir: s.configDir }); assert.equal(s.read().users.length, 1);
for (const args of [['list', '--tester'], ['list', '--subject', 'pocket:x'], ['user', 'add', 'chosen-id', '--name', 'Pat', '--subject', 'pocket:x', '--revision', '1'], ['list', '--json', '--json']]) await assert.rejects(s.run(args));
await assert.rejects(accountsCommand(['list'], { ...s.io, env: { MOSAIC_RUN_ID: 'run' } }, s.deps), /human shell/);
});
test('review P2-2: real reservation wins before owner.json exists, and removal refuses', async t => {
const s = setup(t); await s.run(['init']); await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat', '--revision', '1']); const id = s.read().users[1].id;
const dataRoot = join(s.configDir, 'data'); mkdirSync(dataRoot, { mode: 0o700 }); const ctx = { configDir: s.configDir, instance: 'test', dataRoot };
const org = await reserveOrg(ctx, 'acme'); assert.equal(existsSync(join(org.root, 'owner.json')), false);
await assert.rejects(s.run(['user', 'remove', id, '--revision', '2']), /org-create-in-progress/); assert.equal(s.read().users.length, 2); assert.equal(s.read().revision, 2);
});
test('host boundary and receipt use effective uid, not real uid (current design clarification)', async t => {
const s = setup(t), realUid = process.getuid, effectiveUid = process.geteuid();
process.getuid = () => effectiveUid + 1;
try { await s.run(['init']); await s.run(['list']); const receipt = JSON.parse(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8')); assert.equal(receipt.uid, effectiveUid); }
finally { process.getuid = realUid; }
});
+44 -2
View File
@@ -7,8 +7,9 @@ stay as the old names: each module there re-exports the module of the same name
here (`src/business.mjs` re-exports `src/organization-config.mjs`), and its
`src/cli.mjs` runs this package's `src/cli.mjs`. Brief: `docs/plans/2026-10-04_slice-1.md`,
row S1. Design: `agents/darkwing/work/slice1-data-model-2026-10-04.md` and its
addenda A and B. The package reads files and refuses on any problem. It
never writes, and it never opens a token file.
addenda A and B. Configuration readers refuse on any problem. Runtime
organization and accounts stores provide explicit locked writes; this package
never opens a token file.
```sh
scripts/mosaic org config validate mosaic-stack
@@ -583,3 +584,44 @@ The optional `plugins.system-type` list enables installed system-type modules fo
an organization. Missing means none. Unknown ids refuse validation. Changes
are owner-only; project selections are separate bus settings and a selection
whose type is disabled remains recorded as inactive.
## Instance accounts (Phase M2)
`src/accounts.mjs` validates `<configDir>/accounts.json` without a dependency or
JSON schema engine, as `access.mjs` does. `checkAccounts(document, {providers})`
and `loadAccounts(file, {providers})` return frozen documents. Providers are the
configured `console.sso.providers` objects (including issuer). Reads refuse an
unsafe config directory, unsafe file, unknown field or malformed row. Account
ids may be organization humans. `accountsRepairs(document)` reports unknown
roles and rights retained on read for rollback; these rows must grant nothing.
M4 supplies the authorization evaluator; M2 alone grants no Console or bus access.
`withAccounts(ctx, {revision, verb, actor?, registry?, init?}, mutate)` takes
`accounts.lock`, reloads, checks the expected file revision, awaits `mutate` on a
copy, maintains `authGen` and membership `rev`, validates and atomically replaces
0600 bytes. Init publishes with a no-clobber hard link; replacements use rename.
Both fsync the config directory before the receipt is appended. `ctx` contains `configDir`, `instance`, and `providers`. Init requires
revision 0 and an absent file. Other writes require an existing valid file. Writes
refuse unknown rights/roles on new or changed rows. Unchanged rollback rows pass
through exactly, so they cannot block account revocation or another row's repair. Role templates are never evaluated as authority by this store.
For a caller already holding accounts.lock,
`withAccountsLock(ctx, handle => withAccountsLocked(handle, options, mutate))`
reuses that live opaque handle. Never acquire an earlier lock from the callback.
The config path must have no symlink ancestors, including the home directory.
The order is lifecycle, registry, accounts. Registry-dependent writes pass the
live document from `withRegistry`; `readRegistryLocked(registry, ctx?)` verifies
the lock and rereads its persisted snapshot without relocking. A changed registry
or an instance mismatch refuses before account publication. Callbacks cannot
reuse a released handle, or run two writes at once through one handle.
Each committed write appends and fsyncs one receipt under the accounts lock in
`accounts-receipts.jsonl`: effective OS uid, actor id, verb, old/new file revisions, changed
account ids with changed field names, and before/after membership rights, scope
and revisions. No account
name, subject, invite binding, hash, raw token or cookie enters the receipt. The
store preflights the receipt destination before replacing accounts. As with
other two-file journals, a crash or IO failure between replacement and receipt
append can leave committed state without its receipt; an observed append failure
has `committed: true` and must be inspected before retrying. It never rolls back
committed authority silently or fabricates a success receipt.
+266
View File
@@ -0,0 +1,266 @@
// Phase M2 (#1873): instance accounts, independent of Console sessions and bus authority.
import { constants, lstatSync } from 'node:fs';
import * as fs from 'node:fs/promises';
import { dirname, join, parse, resolve } from 'node:path';
import { randomBytes } from 'node:crypto';
import { OrgError, readRegistryLocked } from './org-registry.mjs';
import { readJsonFile, requireObject, rejectUnknownKeys, requireString, requirePositiveInt, requireId, requireDistinctList, deepFreeze, canonicalJson } from './util.mjs';
import { RIGHTS, ROLE_RIGHTS, normalizeRights } from './org-rights.mjs';
const fail = (code, message = code) => { throw new OrgError(code, message); };
const bad = message => fail('accounts-invalid', message);
const USER_ID = /^u-[0-9a-f]{12}$/;
const HEX32 = /^[0-9a-f]{32}$/;
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const same = (a, b) => canonicalJson(a) === canonicalJson(b);
const array = (x, at) => { if (!Array.isArray(x)) bad(`${at} must be an array`); return x; };
const text = (x, at) => {
requireString(x, at, { max: 255 });
if (/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/.test(x)) bad(`${at} must not contain control or bidi formatting characters`);
return x;
};
const keys = (x, allowed, at) => { requireObject(x, at); rejectUnknownKeys(x, allowed, at); };
const accountId = (x, at) => { if (typeof x !== 'string' || !USER_ID.test(x)) bad(`${at} must be u- plus 12 lowercase hex characters`); };
function time(x, at) {
if (typeof x !== 'string' || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{1,3})?Z$/.test(x) || !Number.isFinite(Date.parse(x)) || new Date(x).toISOString().slice(0, 19) !== x.slice(0, 19)) bad(`${at} must be a real ISO time`);
}
function providerMap(context) {
const providers = new Map();
for (const p of array(context?.providers, 'accounts context.providers')) {
text(p.id, 'provider.id');
if (p.id === 'local-dev' || typeof p.issuer !== 'string' || !p.issuer.startsWith('https://')) bad('provider must have an https issuer and must not be local-dev');
try { if (new URL(p.issuer).protocol !== 'https:') bad('invalid issuer'); } catch { bad('invalid issuer'); }
if (providers.has(p.id)) bad('duplicate configured provider');
providers.set(p.id, p);
}
return providers;
}
function subject(s, at, providers) {
keys(s, ['provider', 'sub'], at);
if (s.provider === 'local-dev' || !providers.has(s.provider)) bad(`${at}.provider must be a configured provider, never local-dev`);
text(s.sub, `${at}.sub`);
}
function scope(s, at) {
keys(s, ['projects', 'instances', 'expires'], at);
for (const key of ['projects', 'instances']) {
const entries = requireDistinctList(s[key], `${at}.${key}`, x => {
if (x === '*') return;
if (key === 'projects') { if (typeof x !== 'string' || !UUID.test(x)) bad(`${at}.projects must contain UUIDs`); }
else requireId(x, `${at}.instances`);
}, { nonEmpty: true });
if (entries.includes('*') && entries.length !== 1) bad(`${at}.${key} must use "*" alone`);
}
if (s.expires !== undefined) time(s.expires, `${at}.expires`);
}
function rowRights(row, at) {
requireId(row.role, `${at}.role`);
if (row.role === 'owner') bad(`${at}.role is not grantable`);
const rights = requireDistinctList(row.rights, `${at}.rights`, r => {
text(r, `${at}.rights`);
if (r === 'owner' || (Object.hasOwn(RIGHTS, r) && !RIGHTS[r].grantable)) bad(`${at}.rights is not grantable`);
if (!/^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9]*)*$/.test(r)) bad(`${at}.rights contains an invalid name`);
}, { nonEmpty: true });
if (!rights.includes('org.view') || !same(rights, [...rights].sort())) bad(`${at}.rights must be sorted and contain org.view`);
scope(row.scope, `${at}.scope`);
requireId(row.org, `${at}.org`);
if (typeof row.incarnation !== 'string' || !HEX32.test(row.incarnation)) bad(`${at}.incarnation must be 32 lowercase hex characters`);
}
// Pure whole-file validation. Unknown, well-formed rights/roles survive a rollback;
// accountsRepairs exposes them without changing the persisted schema.
export function checkAccounts(document, context) {
const providers = providerMap(context);
keys(document, ['accountsVersion', 'revision', 'users', 'memberships', 'invites'], 'accounts file');
if (document.accountsVersion !== 1) bad('accountsVersion must be 1');
requirePositiveInt(document.revision, 'revision');
const users = new Set(), subjects = new Set(); let operators = 0;
array(document.users, 'users').forEach((u, i) => {
const at = `users[${i}]`;
keys(u, ['id', 'subjects', 'name', 'instanceRole', 'state', 'authGen', 'created'], at);
accountId(u.id, `${at}.id`); if (users.has(u.id)) bad('duplicate account id'); users.add(u.id);
text(u.name, `${at}.name`);
if (!['operator', 'user'].includes(u.instanceRole)) bad(`${at}.instanceRole must be operator or user`);
if (!['active', 'disabled'].includes(u.state)) bad(`${at}.state must be active or disabled`);
requirePositiveInt(u.authGen, `${at}.authGen`); time(u.created, `${at}.created`);
if (u.instanceRole === 'operator' && u.state === 'active') operators++;
array(u.subjects, `${at}.subjects`).forEach((s, j) => {
subject(s, `${at}.subjects[${j}]`, providers); const key = JSON.stringify([s.provider, s.sub]);
if (subjects.has(key)) bad('subject appears more than once in the file'); subjects.add(key);
});
if (!u.subjects.length) bad(`${at}.subjects must not be empty`);
});
if (!operators) bad('at least one active operator must exist');
const memberships = new Set();
array(document.memberships, 'memberships').forEach((m, i) => {
const at = `memberships[${i}]`;
keys(m, ['org', 'incarnation', 'user', 'role', 'rights', 'scope', 'rev', 'added', 'by', 'via'], at);
rowRights(m, at); accountId(m.user, `${at}.user`); if (!users.has(m.user)) bad(`${at}.user must name an account`);
accountId(m.by, `${at}.by`); text(m.via, `${at}.via`); time(m.added, `${at}.added`); requirePositiveInt(m.rev, `${at}.rev`);
const key = JSON.stringify([m.org, m.incarnation, m.user]); if (memberships.has(key)) bad('duplicate membership'); memberships.add(key);
});
const invites = new Set(), hashes = new Set();
array(document.invites, 'invites').forEach((invite, i) => {
const at = `invites[${i}]`;
keys(invite, ['id', 'org', 'incarnation', 'role', 'rights', 'scope', 'bind', 'tokenHash', 'by', 'created', 'expires', 'state', 'user', 'used'], at);
rowRights(invite, at); text(invite.id, `${at}.id`); if (!UUID.test(invite.id)) bad(`${at}.id must be a UUID`);
if (invites.has(invite.id)) bad('duplicate invite id'); invites.add(invite.id);
if (typeof invite.tokenHash !== 'string' || !/^[0-9a-f]{64}$/.test(invite.tokenHash) || hashes.has(invite.tokenHash)) bad(`${at}.tokenHash must be a unique sha256 hex hash`); hashes.add(invite.tokenHash);
accountId(invite.by, `${at}.by`); time(invite.created, `${at}.created`); time(invite.expires, `${at}.expires`);
if (Date.parse(invite.expires) <= Date.parse(invite.created)) bad(`${at}.expires must follow created`);
const ttl = Date.parse(invite.expires) - Date.parse(invite.created);
if (ttl < 3600000 || ttl > 168 * 3600000) bad(`${at} validity must be between 1 and 168 hours`);
if (!['open', 'used', 'revoked', 'declined', 'expired'].includes(invite.state)) bad(`${at}.state is invalid`);
requireObject(invite.bind, `${at}.bind`);
if (Object.hasOwn(invite.bind, 'email')) { keys(invite.bind, ['email'], `${at}.bind`); text(invite.bind.email, `${at}.bind.email`); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(invite.bind.email)) bad(`${at}.bind.email must be one address`); }
else subject(invite.bind, `${at}.bind`, providers);
if (invite.state === 'used') { accountId(invite.user, `${at}.user`); time(invite.used, `${at}.used`); }
else if (invite.user !== undefined || invite.used !== undefined) bad(`${at} unused invite has usage fields`);
});
return deepFreeze(structuredClone(document));
}
export function accountsRepairs(doc) {
const result = [];
for (const [kind, rows] of [['membership', doc.memberships], ['invite', doc.invites]]) for (const row of rows) {
const unknownRole = Object.hasOwn(ROLE_RIGHTS, row.role) ? undefined : row.role;
const unknownRights = row.rights.filter(r => !Object.hasOwn(RIGHTS, r));
if (unknownRole || unknownRights.length) result.push({ kind, org: row.org, ...(kind === 'membership' ? { user: row.user } : { id: row.id }), ...(unknownRole ? { unknownRole } : {}), unknownRights });
}
return result;
}
// Check the host boundary before any read or lock. Every ancestor must be a real
// directory; configDir itself belongs to this uid and is not publicly writable.
export function assertAccountsOwner(configDir, { uid = process.geteuid() } = {}) {
if (typeof configDir !== 'string' || resolve(configDir) !== configDir) fail('accounts-unsafe', 'config directory must be an absolute normalized path');
let current = parse(configDir).root;
for (const part of configDir.slice(current.length).split('/').filter(Boolean)) {
current = join(current, part); const st = lstatSync(current);
if (!st.isDirectory() || st.isSymbolicLink()) fail('accounts-unsafe', 'config path contains a symlink or non-directory');
}
const st = lstatSync(configDir);
if (st.uid !== uid) fail('accounts-unsafe', `config directory must belong to uid ${uid}: ${configDir}`);
if (st.mode & 0o022) fail('accounts-unsafe', 'config directory must not be writable by group or other');
}
export function loadAccounts(file, context, { uid = process.geteuid() } = {}) {
assertAccountsOwner(dirname(file), { uid });
const doc = readJsonFile(file, 'accounts file', st => {
if (st.uid !== uid) fail('accounts-unsafe', `accounts file must belong to uid ${uid}: ${file}`);
if (st.mode & 0o022) fail('accounts-unsafe', 'accounts file must not be writable by group or other');
});
return checkAccounts(doc, context);
}
export const mintAccountId = () => `u-${randomBytes(6).toString('hex')}`;
const heldAccounts = new WeakMap();
// Low-level helper for callers already holding the earlier lifecycle/registry
// locks. A handle is valid only within this callback and serializes all writes.
export async function withAccountsLock(ctx, fn) {
assertAccountsOwner(ctx.configDir);
const lock = join(ctx.configDir, 'accounts.lock'); let fd;
for (let n = 0; n < 200; n++) {
try { fd = await fs.open(lock, 'wx', 0o600); break; }
catch (e) { if (e.code !== 'EEXIST') throw e; await new Promise(r => setTimeout(r, 10)); }
}
if (!fd) fail('accounts-locked', 'accounts lock is held; inspect it before retrying');
const handle = Object.freeze({}); heldAccounts.set(handle, { ctx, writing: false });
try { return await fn(handle); }
finally { heldAccounts.delete(handle); await fd.close(); await fs.unlink(lock); }
}
export async function withAccounts(ctx, options, fn) {
if (options.registry) await readRegistryLocked(options.registry, ctx);
return withAccountsLock(ctx, handle => withAccountsLocked(handle, options, fn));
}
const VERBS = new Set(['init', 'user.add', 'user.disable', 'user.enable', 'user.signout', 'user.remove', 'user.update', 'membership.add', 'membership.rights', 'membership.remove', 'membership.leave', 'invite.create', 'invite.accept', 'invite.decline', 'invite.revoke', 'org.delete', 'orphans.sweep']);
function generations(next, before, verb) {
for (const u of next.users) {
const old = before?.users.find(x => x.id === u.id);
if (!old) { u.authGen = 1; continue; }
const changed = u.state !== old.state || u.instanceRole !== old.instanceRole || !same(u.subjects, old.subjects);
if (changed) u.authGen = old.authGen + 1;
else if (u.authGen !== old.authGen && !(verb === 'user.signout' && u.authGen === old.authGen + 1)) bad('authGen may only advance one generation for signout or an authentication change');
if (u.created !== old.created) bad('account created time is immutable');
}
for (const m of next.memberships) {
const old = before?.memberships.find(x => x.org === m.org && x.incarnation === m.incarnation && x.user === m.user);
// A rollback row grants nothing, but must not block unrelated account
// revocation or the repair of another row. Preserve unchanged rows exactly.
if (old && same(m, old)) continue;
if (!Object.hasOwn(ROLE_RIGHTS, m.role) || m.role === 'owner') fail('unknown-role', 'unknown or ungrantable membership role');
m.rights = [...normalizeRights(m.rights)];
m.rev = old ? old.rev + (!same(old.rights, m.rights) || !same(old.scope, m.scope) ? 1 : 0) : 1;
}
for (const i of next.invites) {
const old = before?.invites.find(x => x.id === i.id);
if (old && same(i, old)) continue;
if (!Object.hasOwn(ROLE_RIGHTS, i.role) || i.role === 'owner') fail('unknown-role', 'unknown or ungrantable invite role');
i.rights = [...normalizeRights(i.rights)];
}
}
// Receipt construction has no free-form text: no names, subjects, bindings,
// tokens, hashes, caller-supplied objects, or cookies can enter the log.
function receipt(before, after, { verb, actor }, at) {
if (!VERBS.has(verb)) bad('unsupported receipt verb');
if (actor !== undefined && actor !== 'human') accountId(actor, 'receipt actor');
const membership = m => ({ org: m.org, incarnation: m.incarnation, user: m.user, role: Object.hasOwn(ROLE_RIGHTS, m.role) ? m.role : 'unknown', rights: m.rights, scope: m.scope, rev: m.rev });
return { at, uid: process.geteuid(), actor: actor ?? 'human', verb, revisionBefore: before?.revision ?? 0, revisionAfter: after.revision,
users: after.users.filter(u => !before || !same(u, before.users.find(x => x.id === u.id))).map(u => u.id),
userChanges: after.users.flatMap(u => {
const old = before?.users.find(x => x.id === u.id);
const changed = ['subjects', 'name', 'instanceRole', 'state', 'authGen', 'created'].filter(key => !old || !same(u[key], old[key]));
return changed.length ? [{ id: u.id, changed }] : [];
}),
removedUsers: (before?.users ?? []).filter(u => !after.users.some(x => x.id === u.id)).map(u => u.id),
memberships: { before: (before?.memberships ?? []).map(membership), after: after.memberships.map(membership) } };
}
async function syncDirectory(dir) {
const fd = await fs.open(dir, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
try { await fd.sync(); } finally { await fd.close(); }
}
// Re-read under the lock, compare expected revision, validate and atomically
// replace 0600 bytes, then append and fsync exactly one receipt while still held.
// A post-rename receipt failure reports committed=true: retrying is not safe.
export async function withAccountsLocked(handle, options, fn) {
const held = heldAccounts.get(handle);
if (!held || held.writing) fail('accounts-locked', 'a live, unused accounts lock handle is required');
held.writing = true;
const { ctx } = held; const file = join(ctx.configDir, 'accounts.json');
let temp, receiptFd, committed = false;
try {
if (options.registry) await readRegistryLocked(options.registry, ctx);
assertAccountsOwner(ctx.configDir);
let before;
if (options.init) {
try { await fs.lstat(file); fail('accounts-exists', 'accounts.json already exists'); } catch (e) { if (e.code !== 'ENOENT') throw e; }
if (options.revision !== 0) fail('revision-conflict');
} else before = loadAccounts(file, ctx);
if (!Number.isSafeInteger(options.revision) || options.revision !== (before?.revision ?? 0)) fail('revision-conflict', `revision-conflict: accounts revision is ${before?.revision ?? 0}`);
const next = before ? structuredClone(before) : { accountsVersion: 1, revision: 0, users: [], memberships: [], invites: [] };
await fn(next);
generations(next, before, options.verb); next.revision = (before?.revision ?? 0) + 1;
const checked = checkAccounts(next, ctx);
const line = receipt(before, checked, options, new Date().toISOString());
// Refuse unsafe receipt destinations before publishing the account change.
receiptFd = await fs.open(join(ctx.configDir, 'accounts-receipts.jsonl'), constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
const st = await receiptFd.stat();
if (!st.isFile() || st.uid !== process.geteuid() || (st.mode & 0o022) || st.nlink !== 1) fail('accounts-unsafe', 'unsafe accounts receipt file');
temp = join(ctx.configDir, `.accounts-${randomBytes(12).toString('hex')}`);
const fd = await fs.open(temp, 'wx', 0o600);
try { await fd.writeFile(JSON.stringify(checked, null, 2) + '\n'); await fd.sync(); } finally { await fd.close(); }
await options.beforeReplace?.();
if (options.registry) await readRegistryLocked(options.registry, ctx);
// Defend against a non-cooperating writer even though cooperative writers
// already serialize through accounts.lock.
if (before) { if (!same(loadAccounts(file, ctx), before)) fail('revision-conflict', 'accounts changed before replace'); }
// Init must never replace a target, including one created by a writer that
// ignores accounts.lock. link publishes the complete inode or fails EEXIST.
if (before) await fs.rename(temp, file);
else await fs.link(temp, file);
committed = true;
if (!before) await fs.unlink(temp);
await (options.syncDirectory ?? syncDirectory)(ctx.configDir);
await receiptFd.writeFile(JSON.stringify(line) + '\n'); await receiptFd.sync();
return checked;
} catch (e) { if (committed) e.committed = true; throw e; }
finally { if (temp) await fs.rm(temp, { force: true }); if (receiptFd) await receiptFd.close(); held.writing = false; }
}
+3 -31
View File
@@ -41,34 +41,6 @@ function noBoundKeys(entry, at) {
}
}
// A var value and every leaf it holds, as a var check's message quotes them (JSON.stringify).
function quoted(value, out = []) {
if (Array.isArray(value)) value.forEach((v) => quoted(v, out));
else if (isPlainObject(value)) Object.values(value).forEach((v) => quoted(v, out));
if (value !== undefined) out.push(JSON.stringify(value));
return out;
}
// checkVars, but a refusal never carries a value. Var checks quote the value they refuse, a value
// can be a secret pasted in the wrong place, and these refusals reach the Console. Each key is
// rechecked alone to name the one at fault; a message that quotes its value is replaced by one
// that names the key only. A rule across keys (the fallback set) names keys only already.
function checkVarsNamingKeys(vars, layer, where) {
try { return checkVars(vars, layer, where); }
catch (error) {
if (error?.name !== "BusinessError" || !isPlainObject(vars)) throw error;
for (const [key, value] of Object.entries(vars)) {
try { checkVars({ [key]: value }, layer, where); }
catch (one) {
if (one?.name !== "BusinessError") throw one;
if (quoted(value).some((text) => one.message.includes(text))) refuse(`${where}: variable ${key} has a value it can't take (the value is not shown)`);
throw one;
}
}
throw error;
}
}
// The root file's document. `where` names it in messages, never with a host path.
export function checkRootAgents(document, { rolesDir, where = "root agents" } = {}) {
requireObject(document, where);
@@ -94,7 +66,7 @@ export function checkRootAgents(document, { rolesDir, where = "root agents" } =
agents[name] = {
definition: entry.definition,
title: definition.title,
vars: checkVarsNamingKeys(entry.vars ?? {}, "agent", `${at}.vars`),
vars: checkVars(entry.vars ?? {}, "agent", `${at}.vars`),
excludeOrgs: entry.excludeOrgs === undefined ? [] : requireDistinctList(entry.excludeOrgs, `${at}.excludeOrgs`, (id) => requireId(id, `${at}.excludeOrgs entry`)),
};
}
@@ -149,7 +121,7 @@ export function checkProjectAgents(agents, { rolesDir, where }) {
if (Object.hasOwn(entry, "definition")) {
const definition = definitionOf(entry.definition, at, rolesDir, cache);
if (entry.definition === CHIEF || entry.definition === ASSISTANT) refuse(`${at}: ${entry.definition} can't be a project agent`);
out[name] = { form: "define", definition: entry.definition, title: definition.title, vars: checkVarsNamingKeys(entry.vars ?? {}, "agent", `${at}.vars`) };
out[name] = { form: "define", definition: entry.definition, title: definition.title, vars: checkVars(entry.vars ?? {}, "agent", `${at}.vars`) };
continue;
}
if (!isPlainObject(entry.vars) || Object.keys(entry.vars).length === 0) refuse(`${at}: an entry defines (definition), narrows (vars) or excludes (exclude)`);
@@ -157,7 +129,7 @@ export function checkProjectAgents(agents, { rolesDir, where }) {
for (const key of Object.keys(entry.vars)) {
if (!INTERSECT.includes(key)) refuse(`${at}.vars: a project can only narrow ${INTERSECT.join(", ")}, not ${JSON.stringify(key)}`);
}
out[name] = { form: "narrow", vars: checkVarsNamingKeys(entry.vars, "project", `${at}.vars`) };
out[name] = { form: "narrow", vars: checkVars(entry.vars, "project", `${at}.vars`) };
}
return deepFreeze(out);
}
@@ -128,7 +128,7 @@ export function checkRoutes({ proposed, old = null, levels, catalogue = null })
for (const [harness, model, thinking] of ROUTES) {
// A missing model refuses no-route at launch, not here.
if (proposed.launch && Object.hasOwn(vars, model) && !Object.hasOwn(proposed.launch.models, vars[model])) {
throw new OrganizationError("unknown-model", `${instance} ${model} ${JSON.stringify(vars[model] ?? null)} isn't a launch.models key`);
throw new OrganizationError("unknown-model", `${instance} variable ${model} isn't a launch.models key`);
}
const currentEntry = proposed.launch?.models?.[vars[model]];
const previousEntry = old?.launch?.models?.[before[model]];
@@ -149,12 +149,12 @@ export function checkRoutes({ proposed, old = null, levels, catalogue = null })
}
if (!Object.hasOwn(vars, harness) || (vars[harness] === before[harness] && vars[thinking] === before[thinking])) continue;
const name = vars[harness];
if (!on.includes(name)) throw new OrganizationError("harness-off", `${instance} ${harness} ${name} isn't switched on in plugins.harness`);
if (!on.includes(name)) throw new OrganizationError("harness-off", `${instance} variable ${harness} isn't switched on in plugins.harness`);
if (vars[thinking] === undefined) continue;
const applies = levels(name);
if (!Array.isArray(applies)) throw new OrganizationError("harness-refused", `harness module ${name} didn't load, so its thinking levels are unknown`);
if (!Array.isArray(applies)) throw new OrganizationError("harness-refused", `${instance} variable ${harness}: its harness module didn't load, so its thinking levels are unknown`);
if (!applies.includes(vars[thinking])) {
throw new OrganizationError("thinking-unsupported", `harness ${name} doesn't apply thinking ${vars[thinking]} (${instance} ${thinking}); it applies ${applies.join(", ")}`);
throw new OrganizationError("thinking-unsupported", `${instance} variable ${thinking}: its harness doesn't apply this thinking level; it applies ${applies.join(", ")}`);
}
}
}
@@ -374,7 +374,7 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now
const entry = offered ? { provider: offered.provider, model: offered.model, module: offered.module, upstream: offered.upstream } : null;
if (!existing && !offered && catalogue?.reason === 'model-key-collision') throw new OrganizationError('model-key-collision');
if (!existing && !offered && catalogue?.status === 'unavailable') throw new OrganizationError('model-catalogue-unavailable');
if (!existing && !offered) throw new OrganizationError('unknown-model', `${instance} model isn't a launch.models key`);
if (!existing && !offered) throw new OrganizationError('unknown-model', `${instance} variable ${prefix}model isn't a launch.models key`);
if (existing && offered && canonicalJson(existing) !== canonicalJson(entry) && !unchanged) throw new OrganizationError('model-key-collision');
if (!existing || existing.module) {
if (unchanged) continue;
+17 -2
View File
@@ -36,14 +36,29 @@ export async function readRegistry({ configDir }) {
return validateRegistry(doc, file);
}
export async function listOrgs(ctx) { const doc = await readRegistry(ctx); return doc ? Object.values(doc.orgs) : []; }
const heldRegistries = new WeakMap();
// Only a live withRegistry document may be reused by a later accounts-lock
// holder. Recheck the persisted snapshot without reacquiring this lock.
export async function readRegistryLocked(registry, ctx) {
const held = heldRegistries.get(registry);
if (!held) fail('org-locked', 'a live registry lock document is required');
if (ctx && (ctx.configDir !== held.ctx.configDir || ctx.instance !== held.ctx.instance)) fail('org-mismatch', 'registry lock belongs to another instance');
const current = await readRegistry(held.ctx);
if (JSON.stringify(current) !== held.persisted) fail('registry-changed', 'registry-changed before accounts replace');
return validateRegistry(registry, join(held.ctx.configDir, 'org-runtime.json'));
}
export async function withRegistry(ctx, fn, { write = true } = {}) {
await safeDir(ctx.configDir); await privateDir(ctx.configDir); const lock = join(ctx.configDir, 'org-runtime.lock'); let fd;
for (let n = 0; n < 200; n++) { try { fd = await fs.open(lock, 'wx', 0o600); break; } catch (e) { if (e.code !== 'EEXIST') throw e; await new Promise(r => setTimeout(r, 10)); } }
if (!fd) fail('org-locked', 'registry lock is held', lock);
try {
const registry = await readRegistry(ctx) ?? { version: 1, instance: ctx.instance, orgs: {} };
const persisted = await readRegistry(ctx);
const registry = persisted ?? { version: 1, instance: ctx.instance, orgs: {} };
if (registry.instance !== ctx.instance) fail('org-mismatch', 'registry instance mismatch');
const result = await fn(registry);
heldRegistries.set(registry, { ctx, persisted: JSON.stringify(persisted) });
let result;
try { result = await fn(registry); }
finally { heldRegistries.delete(registry); }
if (write) {
const file = join(ctx.configDir, 'org-runtime.json'); const temp = join(ctx.configDir, `.org-runtime-${randomBytes(12).toString('hex')}`);
try { await fs.writeFile(temp, JSON.stringify(registry, null, 2) + '\n', { mode: 0o600, flag: 'wx' }); await fs.rename(temp, file); } finally { await fs.rm(temp, { force: true }); }
@@ -205,7 +205,7 @@ function checkLaunch(launch, roles, definitions, file) {
for (const name of Object.keys(limits.tokens?.daily ?? {})) {
if (name !== account?.name) refuse(`${where}.limits.tokens.daily names ${name}, which isn't ${where}.account.name`);
}
for(const [instance,role] of Object.entries(roles))if(role.vars.model!==undefined&&!Object.hasOwn(models,role.vars.model))refuse(`${where}: instance ${instance} selects unknown model ${role.vars.model}`);
for(const [instance,role] of Object.entries(roles))if(role.vars.model!==undefined&&!Object.hasOwn(models,role.vars.model))refuse(`${where}: instance ${instance} variable model selects an unknown model`);
// No upper bound: limits have no ceiling in code (Q150).
const threadIdleSeconds = launch.threadIdleSeconds === undefined ? THREAD_IDLE_SECONDS : requirePositiveInt(launch.threadIdleSeconds, `${where}.threadIdleSeconds`);
// Row 194 (#1692, D2.4, D2.5): the human's per-agent forbid. keep names declared instances the
+27 -13
View File
@@ -16,7 +16,7 @@
import { refuse } from "./errors.mjs";
import { ACTIONS, NETWORKS, TOOLS } from "./vocabulary.mjs";
import { isPlainObject, requireDistinctList, requirePositiveInt, requireString } from "./util.mjs";
import { isPlainObject, requireString } from "./util.mjs";
import { NAME as MODULE_NAME } from "../../plugins/src/kinds.mjs";
export const LAYERS = Object.freeze(["system", "business", "project", "agent"]);
@@ -29,7 +29,7 @@ function string(max = 200) {
function oneOf(values) {
return (v, key) => {
if (!values.includes(v)) refuse(`variable ${key} must be one of: ${values.join(", ")} (got ${JSON.stringify(v)})`);
if (!values.includes(v)) refuse(`variable ${key} must be one of: ${values.join(", ")}`);
return v;
};
}
@@ -37,13 +37,13 @@ function oneOf(values) {
// A harness plug-in module's name, in the loader's pattern (row 65, REQ-PLUG-2). Whether the
// module exists or is switched on is checked at launch and at a write, never at load.
function moduleName(v, key) {
if (typeof v !== "string" || !MODULE_NAME.test(v)) refuse(`variable ${key} must be a harness module name (got ${JSON.stringify(v)})`);
if (typeof v !== "string" || !MODULE_NAME.test(v)) refuse(`variable ${key} must be a harness module name`);
return v;
}
function integer(min) {
return (v, key) => {
if (!Number.isSafeInteger(v) || v < min) refuse(`variable ${key} must be an integer of at least ${min} (got ${JSON.stringify(v)})`);
if (!Number.isSafeInteger(v) || v < min) refuse(`variable ${key} must be an integer of at least ${min}`);
return v;
};
}
@@ -55,7 +55,7 @@ function baseUrl(v, key) {
try {
url = new URL(v);
} catch {
refuse(`variable ${key} must be an http or https URL (got ${JSON.stringify(v)})`);
refuse(`variable ${key} must be an http or https URL`);
}
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash || v.endsWith("/")) {
refuse(`variable ${key} must be an http or https base URL with no credentials, query, fragment or trailing slash`);
@@ -70,21 +70,31 @@ function searxngUrl(v, key) {
baseUrl(v, key);
const url = new URL(v);
const loopback = ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname);
if (url.protocol === "http:" && !loopback) refuse(`variable ${key} must be https, or http on loopback (got ${JSON.stringify(v)})`);
if (url.protocol === "http:" && !loopback) refuse(`variable ${key} must be https, or http on loopback`);
return v;
}
function branch(v, key) {
if (typeof v !== "string" || !BRANCH.test(v)) refuse(`variable ${key} must be a git branch name (got ${JSON.stringify(v)})`);
if (typeof v !== "string" || !BRANCH.test(v)) refuse(`variable ${key} must be a git branch name`);
return v;
}
function list(check, { nonEmpty = true } = {}) {
return (v, key) => requireDistinctList(v, `variable ${key}`, (item) => check(item, `${key} entry`), { nonEmpty });
return (v, key) => {
if (!Array.isArray(v)) refuse(`variable ${key} must be an array`);
if (nonEmpty && v.length === 0) refuse(`variable ${key} must not be empty`);
const seen = new Set();
for (const item of v) {
check(item, `${key} entry`);
if (seen.has(item)) refuse(`duplicate entry in variable ${key}`);
seen.add(item);
}
return [...seen];
};
}
function boolean(v, key) {
if (typeof v !== "boolean") refuse(`variable ${key} must be true or false (got ${JSON.stringify(v)})`);
if (typeof v !== "boolean") refuse(`variable ${key} must be true or false`);
return v;
}
@@ -125,7 +135,10 @@ export const REGISTRY = Object.freeze({
"bus.tester": def(["business"], boolean, { default: false }),
// Project.
"tracker.project": def(["project"], (v, key) => requirePositiveInt(v, `variable ${key}`)),
"tracker.project": def(["project"], (v, key) => {
if (!Number.isSafeInteger(v) || v < 1) refuse(`variable ${key} must be a positive integer`);
return v;
}),
// On: the tracker project is the root of a tree, and sync reads its descendants too.
"tracker.descendants": def(["project"], boolean, { default: false }),
"git.workingBranch": def(["project"], branch),
@@ -161,16 +174,17 @@ export const REGISTRY = Object.freeze({
// Limits narrow the role definition's ceilings. limits.authority is an
// allowlist: a role action it doesn't name becomes gated.
"limits.tools": def(["business", "project", "agent"], list((v, key) => {
if (!TOOLS.includes(v)) refuse(`variable ${key} names an unsupported tool: ${JSON.stringify(v)}`);
if (!TOOLS.includes(v)) refuse(`variable ${key} names an unsupported tool`);
}, { nonEmpty: false }), { merge: "intersect" }),
"limits.network": def(["business", "project", "agent"], oneOf(NETWORKS), { merge: "intersect" }),
"limits.authority": def(["business", "project", "agent"], list((v, key) => {
if (!ACTIONS.includes(v)) refuse(`variable ${key} names an unknown action: ${JSON.stringify(v)}`);
if (!ACTIONS.includes(v)) refuse(`variable ${key} names an unknown action`);
}, { nonEmpty: false }), { merge: "intersect" }),
});
// Check one layer's vars object. `where` names the file and path for the
// message. Returns a frozen copy with checked values.
// message. Returns a frozen copy with checked values. Validators name the key
// and the rule, never a value: refusals can reach the Console and the trail.
export function checkVars(vars, layer, where) {
if (!LAYERS.includes(layer)) throw new Error(`unknown layer ${layer}`);
if (!isPlainObject(vars)) refuse(`${where} must be a JSON object`);
@@ -0,0 +1,180 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, chmodSync, symlinkSync, unlinkSync, existsSync, readdirSync, rmSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { checkAccounts, loadAccounts, accountsRepairs, withAccounts, withAccountsLock, withAccountsLocked } from '../src/accounts.mjs';
import { templateRender } from '../src/org-service.mjs';
import { loadBusiness } from '../src/organization-config.mjs';
import { REPO_ROLES } from './helpers.mjs';
import { withRegistry, readRegistryLocked } from '../src/org-registry.mjs';
export const OP = 'u-000000000001', USER = 'u-000000000002';
export const context = { providers: [{ id: 'pocket', issuer: 'https://id.example', subjects: [{ sub: 'owner' }] }] };
export const user = (id = OP, sub = 'owner', instanceRole = 'operator') => ({ id, subjects: [{ provider: 'pocket', sub }], name: 'Person', instanceRole, state: 'active', authGen: 1, created: '2026-11-10T00:00:00Z' });
export const member = () => ({ org: 'acme', incarnation: 'a'.repeat(32), user: USER, role: 'member', rights: ['board.read', 'org.view', 'thread.read'], scope: { projects: ['*'], instances: ['*'] }, rev: 1, added: '2026-11-10T00:00:00Z', by: OP, via: 'invite:abc' });
export const document = () => ({ accountsVersion: 1, revision: 1, users: [user(), user(USER, 'member', 'user')], memberships: [], invites: [] });
function setup(t, doc = document()) {
const dir = mkdtempSync(join(tmpdir(), 'a-')); chmodSync(dir, 0o700); t.after(() => rmSync(dir, { recursive: true, force: true }));
const file = join(dir, 'accounts.json'); writeFileSync(file, JSON.stringify(doc), { mode: 0o600 });
return { configDir: dir, instance: 'test', ...context, file };
}
const write = (ctx, revision, fn, rest = {}) => withAccounts(ctx, { revision, verb: 'user.signout', ...rest }, fn);
test('loader refuses every malformed document as a whole, including nested unknown keys', t => {
const cases = [d => d.accountsVersion = 2, d => d.revision = 0, d => d.cookie = 'secret', d => d.users[0].id = 'human', d => d.users[0].authGen = 0, d => d.users[0].authGen = 1.5, d => d.users[0].instanceRole = 'owner', d => d.users[0].state = 'disabled', d => d.users[0].subjects[0].provider = 'local-dev', d => d.users[0].subjects[0].provider = 'missing', d => d.users[1].subjects = d.users[0].subjects, d => d.users[0].subjects[0].iss = 'https://evil', d => d.users[0].created = '2026-02-30T00:00:00Z', d => d.memberships.push({}), d => { const m = member(); m.rights.push('org.setup'); d.memberships.push(m); }, d => { const m = member(); m.rights.reverse(); d.memberships.push(m); }, d => { const m = member(); m.scope.instances = ['*', 'coder']; d.memberships.push(m); }, d => { const m = member(); m.user = 'u-000000000003'; d.memberships.push(m); }];
for (const mutate of cases) { const d = document(); mutate(d); assert.throws(() => checkAccounts(d, context), undefined, mutate.toString()); }
assert.throws(() => checkAccounts(document(), { providers: [{ id: 'pocket', issuer: 'local-dev' }] }));
const s = setup(t); assert.throws(() => loadAccounts(s.file, s, { uid: process.getuid() + 1 }), /uid/);
for (const mode of [0o620, 0o602]) { chmodSync(s.file, mode); assert.throws(() => loadAccounts(s.file, s), /writable/); }
chmodSync(s.file, 0o600); const target = join(s.configDir, 'real'); writeFileSync(target, JSON.stringify(document()), { mode: 0o600 }); unlinkSync(s.file); symlinkSync(target, s.file); assert.throws(() => loadAccounts(s.file, s), /symbolic/);
});
test('unknown role and right load as repair rows; account may be a business human', () => {
const d = document(); const m = member(); m.role = 'future'; m.rights.push('zz.future'); d.memberships.push(m);
const loaded = checkAccounts(d, { ...context, businesses: { acme: { human: OP } } });
assert.equal(loaded.users[0].id, OP); assert.ok(Object.isFrozen(loaded.users[0]));
assert.deepEqual(accountsRepairs(loaded), [{ kind: 'membership', org: 'acme', user: USER, unknownRole: 'future', unknownRights: ['zz.future'] }]);
});
test('writes refuse stale revision, unknown role/right and reserved rights without a receipt', async t => {
const s = setup(t); const before = readFileSync(s.file, 'utf8');
await assert.rejects(write(s, 9, () => {}), /revision-conflict/);
for (const change of [m => m.role = 'future', m => m.rights.push('zz.future'), m => m.rights.push('org.setup')]) {
await assert.rejects(write(s, 1, d => { const m = member(); change(m); d.memberships.push(m); }));
}
assert.equal(readFileSync(s.file, 'utf8'), before); assert.equal(existsSync(join(s.configDir, 'accounts-receipts.jsonl')), false);
});
test('rights/scope revisions and security generations are maintained by the writer', async t => {
const d = document(); d.memberships.push(member()); const s = setup(t, d);
let rev = 1;
for (const change of [u => u.state = 'disabled', u => u.state = 'active', u => u.instanceRole = 'operator', u => u.subjects[0].sub = 'new-sub']) {
const result = await write(s, rev++, next => change(next.users[1])); assert.equal(result.users[1].authGen, rev);
}
let result = await write(s, rev++, next => next.users[1].authGen++); assert.equal(result.users[1].authGen, rev);
result = await write(s, rev++, next => next.memberships[0].rights.push('task.write')); assert.equal(result.memberships[0].rev, 2);
result = await write(s, rev++, next => next.memberships[0].scope.instances = ['coder']); assert.equal(result.memberships[0].rev, 3);
assert.equal(result.users[1].authGen, 6);
const lines = readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
assert.equal(lines.length, 7); for (const r of lines) { assert.equal(r.uid, process.getuid()); assert.equal(r.verb, 'user.signout'); }
assert.equal(statSync(s.file).mode & 0o777, 0o600);
});
test('concurrent writes serialize and the later stale write changes nothing', async t => {
const s = setup(t); let release, entered; const ready = new Promise(r => entered = r); const wait = new Promise(r => release = r);
const first = write(s, 1, async d => { entered(); await wait; d.users[1].authGen++; }); await ready;
const second = write(s, 1, d => { d.users[1].authGen++; }); release();
const results = await Promise.allSettled([first, second]); assert.equal(results[0].status, 'fulfilled'); assert.equal(results[1].status, 'rejected'); assert.match(results[1].reason.message, /revision-conflict/);
assert.equal(loadAccounts(s.file, s).revision, 2);
});
test('locked helpers require live handles; registry dependency is rechecked before replace', async t => {
const s = setup(t); let held;
await assert.rejects(readRegistryLocked({}), /lock/);
await assert.rejects(withAccountsLocked({}, { revision: 1, verb: 'user.signout' }, () => {}), /lock/);
await withRegistry(s, async registry => {
assert.equal(await readRegistryLocked(registry), registry);
await withAccountsLock(s, async handle => { held = handle; await withAccountsLocked(handle, { revision: 1, verb: 'user.signout', registry }, d => d.users[1].authGen++); });
});
await assert.rejects(withAccountsLocked(held, { revision: 2, verb: 'user.signout' }, () => {}), /lock/);
const before = readFileSync(s.file, 'utf8');
await withRegistry(s, async registry => {
await assert.rejects(write(s, 2, d => { d.users[1].authGen++; writeFileSync(join(s.configDir, 'org-runtime.json'), JSON.stringify({ version: 1, instance: 'test', orgs: { changed: { id: 'changed', root: '/elsewhere', state: 'active', revision: 1, created: 'now' } } }), { mode: 0o600 }); }, { registry }), /registry-changed/);
}, { write: false });
assert.equal(readFileSync(s.file, 'utf8'), before);
});
test('failure before replace leaves old bytes, no receipt line and no temporary file', async t => {
const s = setup(t); const before = readFileSync(s.file, 'utf8');
await assert.rejects(write(s, 1, d => d.users[1].authGen++, { beforeReplace: () => { throw new Error('injected failure'); } }), /injected failure/);
assert.equal(readFileSync(s.file, 'utf8'), before); assert.deepEqual(readdirSync(s.configDir).filter(f => f.startsWith('.accounts-')), []); assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'), '');
});
test('receipts never serialize subject secrets, tokens, cookies, names or arbitrary actor text', async t => {
const s = setup(t); const secret = 'subject-secret';
await write(s, 1, d => { d.users[1].subjects[0].sub = secret; d.users[1].name = 'cookie-secret'; }, { actor: OP });
const raw = readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'); assert.ok(!raw.includes(secret)); assert.ok(!raw.includes('cookie-secret'));
await assert.rejects(write(s, 2, () => {}, { verb: 'token-secret' }));
await assert.rejects(write(s, 2, () => {}, { actor: 'cookie-secret' }));
});
test('malformed membership rows refuse all of the file; reserved rights never load', () => {
const mutations = [m => m.role = 'owner', m => m.rev = 0, m => m.incarnation = 'old', m => m.rights = ['thread.read'], m => m.rights = ['org.view', 'org.view'], m => m.scope = { projects: [], instances: ['*'] }, m => m.scope.projects = ['not-a-uuid'], m => m.scope.expires = 'not-a-time', m => m.extra = true, m => m.by = 'human', m => m.via = '', m => m.added = 'bad'];
for (const mutate of mutations) { const d = document(), m = member(); mutate(m); d.memberships.push(m); assert.throws(() => checkAccounts(d, context), undefined, mutate.toString()); }
for (const right of ['members.manage', 'org.setup', 'org.lifecycle', 'request.write', 'owner']) { const d = document(), m = member(); m.rights = ['org.view', right].sort(); d.memberships.push(m); assert.throws(() => checkAccounts(d, context)); }
});
function invitation() {
const { user: ignored, rev, added, via, ...row } = member();
return { ...row, id: '11111111-2222-3333-4444-555555555555', bind: { email: '[email protected]' }, tokenHash: 'b'.repeat(64), created: '2026-11-10T00:00:00Z', expires: '2026-11-13T00:00:00Z', state: 'open' };
}
test('invite schema refuses raw secrets, domain/bearer bindings, malformed hashes and unknown fields', () => {
const d = document(); d.invites.push(invitation()); assert.equal(checkAccounts(d, context).invites.length, 1);
for (const mutate of [i => i.token = 'raw-token', i => i.cookie = 'raw-cookie', i => i.bind = { domain: 'example.com' }, i => i.bind = {}, i => i.bind = { email: '@example.com' }, i => i.bind.sub = 'extra-binding', i => i.tokenHash = 'raw-token', i => i.state = 'other', i => i.rights = ['org.setup', 'org.view'], i => i.scope.level = 'collaborator']) {
const next = structuredClone(d); mutate(next.invites[0]); assert.throws(() => checkAccounts(next, context));
}
d.invites[0].role = 'future'; d.invites[0].rights.push('zz.future'); assert.equal(accountsRepairs(checkAccounts(d, context))[0].kind, 'invite');
});
test('unsafe receipt destination refuses before publication and leaves its target intact', async t => {
const s = setup(t), target = join(s.configDir, 'target'); writeFileSync(target, 'untouched', { mode: 0o600 }); symlinkSync(target, join(s.configDir, 'accounts-receipts.jsonl'));
const before = readFileSync(s.file, 'utf8'); await assert.rejects(write(s, 1, d => d.users[1].authGen++)); assert.equal(readFileSync(s.file, 'utf8'), before); assert.equal(readFileSync(target, 'utf8'), 'untouched');
});
test('account lock allows sequential writes and registry handles cannot cross instance boundaries', async t => {
const s = setup(t), other = setup(t);
await withAccountsLock(s, async handle => {
await withAccountsLocked(handle, { revision: 1, verb: 'user.signout' }, d => d.users[1].authGen++);
await withAccountsLocked(handle, { revision: 2, verb: 'user.signout' }, d => d.users[1].authGen++);
});
await withRegistry(other, async registry => { await assert.rejects(write(s, 3, () => {}, { registry }), /another instance/); });
assert.equal(loadAccounts(s.file, s).revision, 3);
});
test('invite token hashes and subject bindings never reach receipts', async t => {
const s = setup(t); const invite = invitation(); invite.bind = { provider: 'pocket', sub: 'subject-secret' };
await write(s, 1, d => d.invites.push(invite), { verb: 'invite.create' });
const raw = readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'); assert.ok(!raw.includes(invite.tokenHash)); assert.ok(!raw.includes('subject-secret')); assert.equal(JSON.parse(raw).verb, 'invite.create');
});
test('a failed rename preserves published bytes and appends no receipt', async t => {
const s = setup(t), before = readFileSync(s.file, 'utf8');
await assert.rejects(write(s, 1, d => d.users[1].authGen++, { beforeReplace: () => { const temp = readdirSync(s.configDir).find(f => f.startsWith('.accounts-')); unlinkSync(join(s.configDir, temp)); } }), { code: 'ENOENT' });
assert.equal(readFileSync(s.file, 'utf8'), before); assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'), ''); assert.ok(!existsSync(join(s.configDir, 'accounts.lock')));
});
test('review P2-1: rollback rows survive unrelated disable/signout but cannot be added or edited', async t => {
const d = document(), m = member(), invite = invitation(); m.role = 'future'; m.rights.push('zz.future'); invite.rights.push('zz.future'); d.memberships.push(m); d.invites.push(invite); const s = setup(t, d);
const disabled = await write(s, 1, next => next.users[1].state = 'disabled', { verb: 'user.disable' });
assert.deepEqual(disabled.memberships, d.memberships); assert.deepEqual(disabled.invites, d.invites);
const signedOut = await write(s, 2, next => next.users[0].authGen++); assert.deepEqual(signedOut.memberships, d.memberships); assert.equal(signedOut.users[0].authGen, 2);
await assert.rejects(write(s, 3, next => next.memberships[0].scope.instances = ['coder']), /unknown/);
await assert.rejects(write(s, 3, next => next.invites[0].state = 'revoked'), /unknown/);
const repaired = await write(s, 3, next => { next.memberships[0].role = 'member'; next.memberships[0].rights = ['org.view']; }); assert.equal(repaired.memberships[0].rev, 2); assert.deepEqual(repaired.invites, d.invites);
});
test('review P3-1: control and bidi characters refuse in names, subjects and provenance', () => {
for (const control of ['\x1b', '\x07', '\r', '\n', '\x7f', '\u0085', '\u202e', '\u2066']) for (const field of ['name', 'sub', 'via']) {
const d = document(); d.memberships.push(member()); if (field === 'via') d.memberships[0].via += control; else if (field === 'sub') d.users[1].subjects[0].sub += control; else d.users[1].name += control;
assert.throws(() => checkAccounts(d, context));
}
});
test('review P3-2: invite validity is between one and 168 hours inclusive', () => {
for (const hours of [0.5, 169, 87600]) { const d = document(), i = invitation(); i.expires = new Date(Date.parse(i.created) + hours * 3600000).toISOString(); d.invites.push(i); assert.throws(() => checkAccounts(d, context)); }
for (const hours of [1, 168]) { const d = document(), i = invitation(); i.expires = new Date(Date.parse(i.created) + hours * 3600000).toISOString(); d.invites.push(i); assert.equal(checkAccounts(d, context).invites.length, 1); }
});
test('review P3-3: receipts record actual changed field names even for a misleading verb', async t => {
const s = setup(t); await write(s, 1, d => d.users[1].instanceRole = 'operator');
const r = JSON.parse(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8')); assert.deepEqual(r.userChanges, [{ id: USER, changed: ['instanceRole', 'authGen'] }]);
});
test('review P3-4: directory durability failure reports committed state and appends no success receipt', async t => {
const s = setup(t); let error;
try { await write(s, 1, d => d.users[1].authGen++, { syncDirectory: async () => { throw new Error('injected directory sync failure'); } }); } catch (e) { error = e; }
assert.match(error?.message ?? '', /injected directory sync failure/); assert.equal(error.committed, true); assert.equal(loadAccounts(s.file, s).revision, 2); assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'), '');
});
test('review P3-5: init cannot clobber a file created after its last absence check', async t => {
const s = setup(t); unlinkSync(s.file); const intruder = 'concurrent file';
await assert.rejects(withAccounts(s, { revision: 0, verb: 'init', init: true, beforeReplace: () => writeFileSync(s.file, intruder, { mode: 0o600 }) }, d => d.users.push(user())), { code: 'EEXIST' });
assert.equal(readFileSync(s.file, 'utf8'), intruder); assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'), '');
});
test('review P3-6: a real business file and accounts file load with the same human id', async t => {
const s = setup(t), doc = await templateRender('acme'); doc.human = OP; mkdirSync(join(s.configDir, 'businesses')); writeFileSync(join(s.configDir, 'businesses', 'acme.json'), JSON.stringify(doc), { mode: 0o600 });
assert.equal(loadBusiness('acme', { dir: s.configDir, rolesDir: REPO_ROLES }).human, OP); assert.equal(loadAccounts(s.file, s).users[0].id, OP);
});
@@ -123,7 +123,7 @@ test("malformed root definitions refuse", () => {
test("a refused var names its key, never its value", () => {
const secret = "sk-secret-marker-7731";
const hides = (fn, key) => assert.throws(fn, (error) => error.name === "BusinessError" && error.message.includes(`variable ${key} has a value it can't take`) && !error.message.includes(secret));
const hides = (fn, key) => assert.throws(fn, (error) => error.name === "BusinessError" && error.message.includes(`variable ${key}`) && !error.message.includes(secret));
hides(() => root({ x: { definition: "reviewer", vars: { thinking: secret } } }), "thinking");
hides(() => root({ x: { definition: "reviewer", vars: { model: "opus-5.5", harness: `${secret}!` } } }), "harness");
hides(() => root({ x: { definition: "reviewer", vars: { "limits.tools": ["read", secret] } } }), "limits.tools");
@@ -251,7 +251,7 @@ test("names that are Object.prototype properties don't count as declared", () =>
refuses(() => check((d) => { d.arbiters.delivery = "constructor"; }), /arbiters\.delivery names constructor/);
refuses(() => check((d) => { d.launch.by = "constructor"; }), /launch\.by names constructor/);
refuses(() => check((d) => { d.launch.instances = ["constructor"]; }), /instances names constructor/);
refuses(() => check((d) => { d.roles.pm.vars.model = "constructor"; }), /unknown model constructor/);
refuses(() => check((d) => { d.roles.pm.vars.model = "constructor"; }), /variable model selects an unknown model/);
refuses(() => check((d) => { d.roles.pm.definition = "constructor"; }), /not found/, 4);
const labels = check((d) => { d.tracker.labels = JSON.parse('{"__proto__": 5}'); }).tracker.labels;
assert.equal(Object.getPrototypeOf(labels), Object.prototype);
@@ -0,0 +1,140 @@
import test from "node:test";
import assert from "node:assert/strict";
import { join } from "node:path";
import {
BusinessError, checkVars, loadBusiness, loadProject, projectFilePath, systemVars,
} from "../src/index.mjs";
import { businessDoc, REPO_ROLES, tmp, writeJson } from "./helpers.mjs";
import { checkRoutes, launchSettings } from "../src/launch-settings.mjs";
import { OrganizationError } from "../src/organization.mjs";
const SECRET = "sk-test-NOT-A-SECRET-1234";
function hidesValue(fn, key, reason) {
assert.throws(fn, (error) => {
assert.ok(error instanceof BusinessError);
assert.equal(error.name, "BusinessError");
assert.equal(error.exitCode, 2);
assert.ok(error.message.includes(`variable ${key}`), error.message);
if (reason) assert.match(error.message, reason);
assert.ok(!error.message.includes(SECRET), "refusal disclosed a var value");
assert.ok(!String(error).includes(SECRET), "rendered error disclosed a var value");
return true;
});
}
for (const [scope, key, value] of [
["business", "tracker.kind", SECRET],
["agent", "thinking", SECRET],
["agent", "model", SECRET],
]) {
test(`organization file ${scope} ${key} refusal names its key and hides its value`, () => {
const root = tmp();
const doc = businessDoc(root);
if (scope === "agent") doc.roles.coder.vars[key] = value;
else doc.vars[key] = value;
writeJson(join(root, "businesses/acme.json"), doc);
hidesValue(() => loadBusiness("acme", { dir: root, rolesDir: REPO_ROLES }), key, key === "model" ? /unknown model/ : /one of/);
});
}
for (const scope of ["project", "role"]) {
test(`project file ${scope} var refusal names its key and hides its value`, () => {
const root = tmp();
const vars = { "tracker.project": SECRET };
writeJson(projectFilePath(root), {
projectVersion: 1, id: "stack",
...(scope === "project" ? { vars } : { roles: { coder: { vars } } }),
});
hidesValue(() => loadProject(root), "tracker.project", /positive integer/);
});
}
test("systemVars accepts user values and refuses malformed ones without quoting them", () => {
const config = {
environment: "development", dataRoot: "/d",
execution: { backend: "docker", provider: "zai", model: "m", adapter: "pi" },
};
for (const key of ["environment", "dataRoot", "execution.backend", "execution.provider", "execution.model", "execution.adapter"]) {
const bad = structuredClone(config);
const value = { pasted: SECRET };
if (key.startsWith("execution.")) bad.execution[key.slice("execution.".length)] = value;
else bad[key] = value;
hidesValue(() => systemVars(bad), key);
}
});
// Exercise every validator that used to quote a scalar, a nested leaf or a duplicate.
// Removing any of their redactions must fail here even if a caller sanitizes its error.
for (const [key, layer, value, reason] of [
["tracker.kind", "business", SECRET, /one of/],
["harness", "agent", `${SECRET}!`, /harness module name/],
["fallback.harness", "agent", `${SECRET}!`, /harness module name/],
["thinking", "agent", { pasted: [SECRET] }, /one of/],
["fallback.thinking", "agent", SECRET, /one of/],
["tracker.reconcileMinutes", "business", SECRET, /integer of at least 1/],
["tracker.pollSeconds", "project", SECRET, /integer of at least 10/],
["tracker.baseUrl", "business", SECRET, /http or https URL/],
["gitea.baseUrl", "business", SECRET, /http or https URL/],
["web.searxng", "business", `http://${SECRET}.example`, /https, or http on loopback/],
["git.workingBranch", "project", `${SECRET}!`, /git branch name/],
["git.protectedBranches", "project", [SECRET, `${SECRET}!`], /git branch name/],
["git.protectedBranches", "project", [SECRET, SECRET], /duplicate entry/],
["suites", "project", [SECRET, SECRET], /duplicate entry/],
["tracker.descendants", "project", SECRET, /true or false/],
["bus.tester", "business", SECRET, /true or false/],
["tracker.project", "project", SECRET, /positive integer/],
["limits.tools", "agent", ["read", SECRET], /unsupported tool/],
["limits.authority", "agent", ["message.send", SECRET], /unknown action/],
["limits.network", "agent", SECRET, /one of/],
]) {
test(`checkVars hides refused ${key}: ${reason.source}`, () => {
hidesValue(() => checkVars({ [key]: value }, layer, "fixture vars"), key, reason);
});
}
for (const prefix of ["", "fallback."]) {
test(`route writer unknown ${prefix}model names its key and hides its value`, () => {
const root = tmp();
const doc = businessDoc(root);
writeJson(join(root, "businesses/acme.json"), doc);
const settings = launchSettings({ dir: root, rolesDir: REPO_ROLES });
const route = { harness: "pi", model: Object.keys(doc.launch.models)[0], thinking: "high", fallback: null };
if (prefix) route.fallback = { harness: "pi", model: SECRET, thinking: "high" };
else route.model = SECRET;
assert.throws(() => settings.setRoutes("acme", {
revision: settings.writer.read("acme").revision, routes: { coder: route },
}, "jason"), error => {
assert.ok(error instanceof OrganizationError);
assert.equal(error.code, "unknown-model");
assert.ok(!String(error).includes(SECRET));
assert.ok(error.message.includes(`variable ${prefix}model`), error.message);
return true;
});
});
for (const [field, code] of [
["model", "unknown-model"], ["harness", "harness-off"],
["harness", "harness-refused"], ["thinking", "thinking-unsupported"],
]) {
test(`route check ${prefix}${field} ${code} names its key and hides its value`, () => {
const vars = prefix ? {
harness: "pi", model: "listed", thinking: "high",
"fallback.harness": "pi", "fallback.model": "listed", "fallback.thinking": "high",
} : { harness: "pi", model: "listed", thinking: "high" };
const key = `${prefix}${field}`;
vars[key] = SECRET;
const proposed = {
roles: { coder: { vars } }, launch: { models: { listed: {} } },
plugins: { harness: code === "harness-refused" ? ["pi", SECRET] : ["pi"] },
};
assert.throws(() => checkRoutes({ proposed, levels: name => name === SECRET ? null : ["high"] }), error => {
assert.ok(error instanceof OrganizationError);
assert.equal(error.code, code);
assert.ok(!String(error).includes(SECRET), "route refusal disclosed a var value");
assert.ok(error.message.includes(`variable ${key}`), error.message);
return true;
});
});
}
}
+4
View File
@@ -5,6 +5,7 @@ import { startServer, previewOptions, proofOptions, DEFAULT_BOARD } from './serv
import { busCaller, busBusiness, userCaller, consoleCall } from './bus.mjs';
import { organizationConsole } from './organizations.mjs';
import { consoleBus, userOptionsFor, agentCaller, TESTER_AGENT_VERBS } from './console-bus.mjs';
import { SYSTEM_TYPE_VERBS } from './system-types-http.mjs';
import { consoleAccess } from './access.mjs';
import { accessContextFor } from '../../cli/src/access.mjs';
import { accessFilePath, loadAccess } from '../../organization/src/access.mjs';
@@ -169,6 +170,9 @@ try {
// The owner reads request status on the selected organization's bus, as on the root Console. Request
// writes stay off: the README "Console actions" inventory marks them unavailable for the organizations release.
requestRead: session.user === undefined ? consoleCall({ dataRoot: root, socket: paths.socket, business: id }) : null,
// Phase P2 (#1873): the owner's System types writes, the organization's enable switch (plugins.set
// with its revision) and a project's selection, over the owner's human transport under --send only.
systemTypesWrite: session.user === undefined && sendOn ? agentCaller(owner, SYSTEM_TYPE_VERBS) : null,
reads: consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: id, orgRoot: root }),
};
},
+4 -1
View File
@@ -620,7 +620,7 @@
html += p && !c.refused && redesigned ? window.MosaicProjects.header(p, c.section, c.threads) : heading(p?.name ?? 'Project unavailable');
if (p && !c.refused) {
if (p.archived && redesigned && !c.canSend) html += '<p class="project-panel" role="status">This project is archived. Its saved conversations and records stay readable.</p>';
if (c.settings) html += `<p><a href="#/projects/${esc(c.id)}">Project overview</a></p><h2>Project information</h2><p>Repository: ${txt(p.repo || 'Not linked')}</p><div data-chat-remote>Repository setting unavailable.</div>`;
if (c.settings) html += `<p><a href="#/projects/${esc(c.id)}">Project overview</a></p><h2>Project information</h2><p>Repository: ${txt(p.repo || 'Not linked')}</p><div data-chat-remote>Repository setting unavailable.</div><div data-chat-system-types>System types unavailable.</div>`;
else if (c.section === 'conversations' && redesigned) html += `<div class="project-section-title"><h2>Conversations</h2></div><div data-project-conversations>${c.threads.filter(t=>!t.shared && t.project===p.id).map(t=>`<a class="project-conversation-row" href="${threadPath(esc(t.id))}" data-conversation-id="${esc(t.id)}"><span class="project-conversation-icon" aria-hidden="true">◌</span><span><strong>${txt(t.name)}</strong><small>With ${txt(t.instance)}</small></span><span data-conversation-status></span><span aria-hidden="true">›</span></a>`).join('') || '<p>No conversations yet. Start one to begin work.</p>'}</div>${p.archived ? '' : `<details class="project-new-conversation"${location.hash.includes('new=1')?' open':''}><summary class="btn primary">New conversation</summary>${creation('thread', c.id)}</details>`}<h2>Shared with me</h2>${c.threads.some(t=>t.shared && t.project===p.id)?threads(c.threads.filter(t=>t.shared && t.project===p.id)):'<p>Nothing is shared with you yet.</p>'}`;
else if (c.section === 'conversations') html += '<h2>Conversations</h2>' + threads(c.threads.filter(t => !t.shared)) + (p.archived ? '' : creation('thread', c.id));
else if (redesigned) html += '<div data-project-content>Reading…</div>';
@@ -684,6 +684,9 @@
const remote = c.container.querySelector('[data-chat-remote]');
const project = c.projects.find(p => p.id === c.id);
if (remote && roomOwner() && typeof window.MosaicProjectRemote?.mount === 'function') { remote.replaceChildren(); window.MosaicProjectRemote.mount({ container: remote, projectId: c.id, repositoryKey: project?.repo ?? null, projectArchived: !!c.projectArchived, preserve: true }); }
// Phase P2 (#1873): the project's System types selection, the owner's, mounted like the remote.
const types = c.container.querySelector('[data-chat-system-types]');
if (types && roomOwner() && typeof window.MosaicSystemTypes?.mount === 'function') window.MosaicSystemTypes.mount({ container: types, projectId: c.id, archived: !!c.projectArchived });
if (!window.MosaicOrg && (!redesigned || c.kind !== 'project' || c.settings || c.projectArchived)) window.MosaicProjectArchive?.mount(c.container, c);
if (redesigned && c.kind === 'project' && c.projectArchived && !c.settings) {
const controls = c.container.querySelector('.project-archive-control');
+1 -1
View File
@@ -7,7 +7,7 @@
<link rel="stylesheet" href="/processing.css"><script src="/processing.js" defer></script>
<link rel="stylesheet" href="/org-create.css"><script src="/org-create.js" defer></script>
<link rel="stylesheet" href="/onboarding.css"><script src="/onboarding.js" defer></script><link rel="stylesheet" href="/panes.css">
<script src="/workspace-data.js" defer></script><script src="/workspace.js" defer></script><script src="/tasks-board.js" defer></script><script src="/console-restart.js" defer></script><script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/panes.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
<script src="/workspace-data.js" defer></script><script src="/workspace.js" defer></script><script src="/tasks-board.js" defer></script><script src="/console-restart.js" defer></script><script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><link rel="stylesheet" href="/system-types.css"><script src="/system-types.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/panes.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
<body>
<div hidden aria-hidden="true"><select id="palette"></select><select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></div>
<a class="skip" href="#main">Skip to content</a>
@@ -0,0 +1,33 @@
/* Phase P2 (#1873): System types on the organization dashboard (.ws-types, the chips and the
Settings row's Open) and on a project's information page (.project-types). */
html[data-organizations] .ws-chip{display:inline-flex;align-items:center;font-size:12px;font-weight:500;padding:1px 7px;border:1px solid var(--line);border-radius:4px;color:var(--text);white-space:nowrap}
html[data-organizations] .ws-cfg button.go{border:0;background:none;padding:0;cursor:pointer;font:inherit;font-size:12.5px;font-weight:600;color:var(--action)}
html[data-organizations] .ws-cfg button.go:hover{text-decoration:underline}
html[data-organizations] .ws-types-list{display:grid;list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--surface)}
html[data-organizations] .ws-types-list li{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:4px 12px;padding:8px 12px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent)}
html[data-organizations] .ws-types-list li:last-child{border-bottom:0}
html[data-organizations] .ws-types-list .t{display:flex;flex-direction:column;min-width:0}
html[data-organizations] .ws-types-list .t b{font-size:13px;font-weight:600;color:var(--strong)}
html[data-organizations] .ws-types-list .t .d{font-size:12px;color:var(--muted);overflow-wrap:anywhere}
html[data-organizations] .ws-types-list .ws-dot{font-size:12.5px}
html[data-organizations] .ws-types-list .ws-btn[aria-disabled="true"]{color:var(--muted);cursor:progress}
html[data-organizations] .ws-types>.note{margin:8px 0 0}
html[data-organizations] .ws-types-status:empty{display:none}
html[data-organizations] .ws-types-status code{overflow-wrap:anywhere}
html[data-organizations] .ws-types h2:focus{outline:none}
@media (max-width:759px){
html[data-organizations] .ws-types-list li{grid-template-columns:minmax(0,1fr) auto}
html[data-organizations] .ws-types-list .t{grid-column:1/-1}
}
.project-types{border:1px solid var(--border, var(--line, #aaa));border-radius:4px;padding:1rem;margin-top:1rem;overflow-wrap:anywhere}
.project-types fieldset{border:0;margin:0;padding:0;display:grid;gap:.5rem}
.project-types legend{font-weight:600;margin-bottom:.25rem}
.project-types-option{display:flex;align-items:flex-start;gap:.5rem;min-height:44px}
.project-types-option input{margin-top:.2rem;width:20px;height:20px;flex:none}
.project-types-option span{display:flex;flex-direction:column;min-width:0}
.project-types-option small{color:var(--muted, inherit)}
.project-types button{min-height:44px;margin-top:.75rem}
.project-types-tag{font-size:.8em;font-weight:600;padding:0 .4em;border:1px solid currentColor;border-radius:4px;color:var(--warning, inherit)}
.project-types [role="status"]:empty{display:none}
.project-types [role="status"]:focus{outline:none}
+195
View File
@@ -0,0 +1,195 @@
// Phase P2 (#1873): System types, the owner's control on the organization dashboard and on a project's
// information page. The modules, their labels and descriptions come from the system-type plug-ins
// through /api/system-types; nothing here lists a module. Writes go to the same route, which uses the
// organization writer (revision, owner fence, receipt) or project.system-types.set; the server and the
// bus recheck every write, so a control shown here grants nothing.
(() => {
'use strict';
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const enc = encodeURIComponent;
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/;
const orgPath = (org, rest) => (org ? `/api/orgs/${enc(org)}/${rest}` : `/api/${rest}`);
const LINES = {
'sign-in-required': 'Sign in again to see or change system types.',
'owner-only': "System types are the organization owner's.",
'read-only': 'This Console only reads. Changing system types needs signed-in send mode.',
'human-required': "Only the organization's owner can change system types.",
'organization-system-types-human-only': "Only the organization's owner can change system types.",
'revision-conflict': 'The organization file changed after this page read it. The list was read again; try again.',
'organization-busy': 'The organization file is being written. Try again in a moment.',
'system-type-not-enabled': 'A chosen type is not enabled in the organization any more. The list was read again.',
'system-type-unknown': 'A chosen type is not installed in this tree.',
'project-not-found': 'This project was not found.',
'not-configured': 'System types are not configured on this Console.',
'system-type-invalid': 'An installed system type module is invalid, so none can be read.',
'no-bus-host': 'The bus is not running.',
'invalid-request': 'The request was refused as invalid.',
};
const line = code => LINES[code] || 'The bus did not confirm the outcome. The list was read again; check it before you retry.';
// One request; a refusal keeps its code so the page can say why.
async function call(path, body) {
const init = body === undefined ? { cache: 'no-store', redirect: 'manual' }
: { method: 'POST', cache: 'no-store', redirect: 'manual', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) };
let response;
try { response = await fetch(path, init); } catch { return { ok: false, code: 'outcome-unknown' }; }
if (response.type === 'opaqueredirect') return { ok: false, code: 'sign-in-required' };
const value = await response.json().catch(() => null);
if (!response.ok) return { ok: false, code: typeof value?.error === 'string' ? value.error : 'outcome-unknown' };
return value && typeof value === 'object' ? { ok: true, value } : { ok: false, code: 'outcome-unknown' };
}
const read = (org, project = null) => call(orgPath(org, project === null ? 'system-types' : `system-types/projects/${enc(project)}`));
const labelOf = (value, id) => value.installed.find(m => m.id === id)?.label || id;
const valid = value => value && Array.isArray(value.installed) && Array.isArray(value.enabled);
// ---------- organization dashboard ----------
// Per organization: the last read, the type a write is pending on, and the last outcome line.
const orgs = new Map();
const slot = org => { if (!orgs.has(org)) orgs.set(org, { value: null, code: null, pending: null, outcome: null }); return orgs.get(org); };
function remember(org, result) {
const s = slot(org);
if (result.ok && valid(result.value)) { s.value = result.value; s.code = null; } else { s.value = null; s.code = result.ok ? 'outcome-unknown' : result.code; }
return s;
}
// The chip row under the header: the enabled modules' labels.
function chips(org) {
const s = slot(org), v = s.value;
const body = !v ? `<span class="note">${esc(s.code === 'owner-only' ? line('owner-only') : 'System types unavailable')}</span>`
: !v.installed.length ? '<span class="note">No system type modules are installed.</span>'
: v.enabled.length ? `${v.enabled.map(id => `<span class="ws-chip">${esc(labelOf(v, id))}</span>`).join('')}<span class="note">Each project picks from these; none is required.</span>`
: '<span class="note">None enabled. Enable them under System types below; each project picks from the enabled ones.</span>';
return `<div class="ws-typechips ws-chips" data-system-type-chips><span class="ws-label">System types</span>${body}</div>`;
}
// The Settings row's value.
function summary(org) {
const v = slot(org).value;
return v ? `${v.enabled.filter(id => v.installed.some(m => m.id === id)).length} of ${v.installed.length} installed enabled` : 'Could not be read';
}
// A side-by-side pane (U5) passes { pane: true, idPrefix }: it shows the types without the switch,
// which stays in the organization's full view, and its heading id carries the pane's prefix.
function section(org, { pane = false, idPrefix = '' } = {}) {
const s = slot(org), v = s.value;
const head = `<div class="ws-sec-h"><h2 id="${esc(idPrefix)}ws-types-h" tabindex="-1" data-system-types-heading>System types</h2><span class="note">installed modules; each project picks from the enabled ones</span></div>`;
const status = `<p class="note ws-types-status" role="status" data-system-types-status>${s.outcome ? s.outcome : ''}</p>`;
if (!v) return `<div class="ws-types" data-system-types>${head}<p class="ws-panel tight note">${esc(s.code === 'owner-only' ? line('owner-only') : `System types unavailable. ${line(s.code)}`)}</p>${status}</div>`;
if (!v.installed.length) return `<div class="ws-types" data-system-types>${head}<p class="ws-panel tight note">No system type modules are installed.</p>${status}</div>`;
const rows = v.installed.map(m => {
const on = v.enabled.includes(m.id), busy = s.pending !== null;
const state = `<span class="ws-dot ${on ? 's-working' : 's-idle'}"><b aria-hidden="true">${on ? '●' : '–'}</b>${on ? 'enabled' : 'off'}</span>`;
const control = v.writable && !pane
? `<button type="button" class="ws-btn" data-system-type-toggle="${esc(m.id)}" data-on="${on}" data-focus-key="${esc(`st:${m.id}`)}" aria-label="${esc(`${on ? 'Disable' : 'Enable'} ${m.label}`)}"${busy ? ' aria-disabled="true"' : ''}>${s.pending === m.id ? 'Saving…' : on ? 'Disable' : 'Enable'}</button>`
: '';
return `<li data-system-type="${esc(m.id)}"><span class="t"><b>${esc(m.label)}</b><span class="d">${esc(m.description)}</span></span>${state}${control}</li>`;
}).join('');
const why = pane ? "<p class=\"note\">Change system types from this organization's full view.</p>"
: v.writable ? '<p class="note">Disabling a type keeps it in the projects that pick it; they show it as inactive until it is enabled again.</p>'
: `<p class="note">${esc(line('read-only'))}</p>`;
return `<div class="ws-types" data-system-types>${head}<ul class="ws-types-list">${rows}</ul>${why}${status}</div>`;
}
// The dashboard asks for this before it paints; the result stays for chips(), summary() and section().
const load = async org => remember(org, await read(org));
// After a write, the three parts are repainted in place; the 10 s dashboard repaint reads again anyway.
function repaint(org) {
if (window.MosaicOrg?.id !== org) return;
const view = [...document.querySelectorAll('[data-org-dashboard]')].find(v => !v.closest('[data-pane]')); if (!view) return;
const a = document.activeElement, key = view.contains(a) ? a.dataset?.focusKey || null : null;
const swap = (selector, html) => { const el = view.querySelector(selector); if (el) el.outerHTML = html; };
swap('[data-system-type-chips]', chips(org));
swap('[data-system-types]', section(org));
const value = view.querySelector('[data-org-setting="System types"] .v'); if (value) { value.textContent = summary(org); value.title = summary(org); }
if (key) view.querySelector(`[data-focus-key="${CSS.escape(key)}"]`)?.focus({ preventScroll: true });
}
async function toggle(button) {
const org = window.MosaicOrg?.id, s = org ? slot(org) : null, type = button.dataset.systemTypeToggle;
if (!s?.value?.writable || s.pending !== null || typeof s.value.revision !== 'string') return;
const on = s.value.enabled.includes(type);
const enabled = on ? s.value.enabled.filter(id => id !== type) : [...s.value.enabled, type];
s.pending = type; s.outcome = null; repaint(org);
const result = await call(orgPath(org, 'system-types'), { revision: s.value.revision, enabled });
s.pending = null;
const label = labelOf(s.value, type);
if (result.ok && typeof result.value.transaction === 'string') {
s.outcome = `${esc(label)} ${on ? 'disabled' : 'enabled'}. Receipt <code>${esc(result.value.transaction)}</code>.`;
} else s.outcome = esc(result.ok ? line('outcome-unknown') : line(result.code));
await load(org);
repaint(org);
}
document.addEventListener('click', e => {
const button = e.target.closest?.('[data-system-type-toggle]');
// A pending write marks the buttons aria-disabled, not disabled, so focus stays on the one pressed.
if (button) { e.preventDefault(); if (button.getAttribute('aria-disabled') !== 'true' && !button.closest('[data-pane]')) toggle(button); return; }
const open = e.target.closest?.('[data-system-types-open]');
if (open) { e.preventDefault(); const h = (open.closest('[data-org-dashboard]') || document).querySelector('[data-system-types-heading]'); h?.scrollIntoView({ block: 'start' }); h?.focus({ preventScroll: true }); }
});
// ---------- project information page ----------
let active = null;
document.addEventListener('mosaic-signed-out', () => { active?.dispose(); active = null; });
// The chat view mounts again on every repaint and keeps the section; leaving the page marks it stale,
// so coming back (say, after the dashboard changed the enabled set) reads again.
window.addEventListener('hashchange', () => { if (active) active.stale = true; });
function mount({ container, projectId, archived = false }) {
if (!(container instanceof Element)) return;
const org = window.MosaicOrg?.id ?? null;
if (active && active.org === org && active.projectId === projectId) {
container.replaceChildren(active.section);
if (active.archived !== archived) { active.archived = archived; active.paint(); }
if (active.stale) { active.stale = false; active.refresh(); }
return;
}
active?.dispose();
const section = document.createElement('section');
section.className = 'project-types';
section.setAttribute('data-project-system-types', '');
container.replaceChildren(section);
let value = null, code = null, outcome = '', saving = false, disposed = false, generation = 0;
const state = { org, projectId, section, archived, stale: false, paint, refresh: () => refresh(), dispose: () => { disposed = true; generation++; section.replaceChildren(); } };
active = state;
function paint() {
if (disposed) return;
const head = '<h2 tabindex="-1">System types</h2>';
const status = `<p role="status" tabindex="-1" data-project-system-types-status>${outcome}</p>`;
if (!ID.test(projectId ?? '')) { section.innerHTML = `${head}<p>System types unavailable.</p>`; return; }
if (!value) { section.innerHTML = `${head}<p>${esc(code ? `System types unavailable. ${line(code)}` : 'Reading system types…')}</p>${status}`; return; }
const sel = value.project, enabled = value.installed.filter(m => value.enabled.includes(m.id));
const inactive = sel.inactive.length
? `<div data-project-system-types-inactive><p>Inactive, because the organization has disabled them:</p><ul>${sel.inactive.map(id => `<li data-inactive-type="${esc(id)}">${esc(labelOf(value, id))} <span class="project-types-tag">inactive</span></li>`).join('')}</ul>${value.writable && !state.archived ? '<p class="muted">Saving keeps only enabled types, so it removes these from the project.</p>' : ''}</div>`
: '';
const intro = '<p>This project picks from the system types its organization has enabled. None is required.</p>';
const canWrite = value.writable && !state.archived;
if (!enabled.length) {
// With nothing enabled the only change left is clearing what the project still selects.
const clear = canWrite && sel.inactive.length
? `<form data-project-system-types-form><button type="submit" class="btn"${saving ? ' disabled' : ''}>${saving ? 'Saving…' : 'Clear the inactive types'}</button></form>` : '';
section.innerHTML = `${head}${intro}<p>The organization has no system types enabled. The owner enables them on the organization dashboard.</p>${inactive}${clear}${status}`;
return;
}
const boxes = enabled.map(m => `<label class="project-types-option"><input type="checkbox" name="system-type" value="${esc(m.id)}"${sel.active.includes(m.id) ? ' checked' : ''}${canWrite && !saving ? '' : ' disabled'}><span><strong>${esc(m.label)}</strong><small>${esc(m.description)}</small></span></label>`).join('');
const why = state.archived ? '<p class="muted">This project is archived; its selection is shown read only.</p>' : value.writable ? '' : `<p class="muted">${esc(line('read-only'))}</p>`;
section.innerHTML = `${head}${intro}<form data-project-system-types-form><fieldset><legend>Selected system types</legend>${boxes}</fieldset>${inactive}${canWrite ? `<button type="submit" class="btn"${saving ? ' disabled' : ''}>${saving ? 'Saving…' : 'Save system types'}</button>` : ''}</form>${why}${status}`;
}
async function refresh() {
const turn = ++generation;
const result = await read(org, projectId);
if (disposed || turn !== generation) return;
if (result.ok && valid(result.value) && result.value.project) { value = result.value; code = null; } else { value = null; code = result.ok ? 'outcome-unknown' : result.code; }
paint();
}
section.addEventListener('submit', async e => {
e.preventDefault();
if (saving || !value?.writable || state.archived) return;
const chosen = [...section.querySelectorAll('input[name="system-type"]:checked')].map(i => i.value);
saving = true; outcome = ''; paint();
const result = await call(orgPath(org, `system-types/projects/${enc(projectId)}`), { systemTypes: chosen });
if (disposed) return;
saving = false;
outcome = result.ok ? esc(result.value.selected.length ? `Saved. Selected: ${result.value.selected.map(id => labelOf(value, id)).join(', ')}.` : 'Saved. No system types selected.') : esc(line(result.code));
await refresh();
section.querySelector('[data-project-system-types-status]')?.focus({ preventScroll: true });
});
paint();
refresh();
}
window.MosaicSystemTypes = Object.freeze({ load, chips, summary, section, mount });
})();
+13 -8
View File
@@ -1,7 +1,8 @@
// The unified workspace screens in organization mode: the sidebar's Organizations and Projects
// groups (sample 01), the organization dashboard (sample 03) and the Agents levels (sample 08).
// Every read goes through MosaicWorkspaceData (workspace-data.js). Nothing here writes; the bus
// restart button is console-restart.js's own control, with its gating and trail unchanged.
// restart button is console-restart.js's own control, with its gating and trail unchanged, and the
// System types chips, row and section are system-types.js's (Phase P2), with its own reads and writes.
(() => {
'use strict';
if (!document.documentElement.hasAttribute('data-organizations')) return;
@@ -154,7 +155,7 @@
const secH = (title, note = '', link = '') => `<div class="ws-sec-h"><h2>${title}</h2>${note ? `<span class="note">${note}</span>` : ''}${link}</div>`;
function settingsList(org, ctx) {
const { business, settings, instances, orgCount } = ctx;
const { business, settings, instances, orgCount, types } = ctx;
const b = business?.business, s = settings;
const unread = 'Could not be read';
const names = instances ? Object.keys(instances) : null;
@@ -166,7 +167,7 @@
const rows = [
['business', 'Identity', b ? `${label(org)} · ${org.id} · kind not recorded` : unread, 'org file', 'business'],
['flag', 'Appearance', `${titleCase(theme)} theme · ${initials(org)} avatar`, b?.appearance?.theme || org.theme ? 'org file' : 'system', 'appearance'],
['cube', 'System types', 'Not built yet', 'not built', null],
['cube', 'System types', types ? types.summary(org.id) : 'Could not be read', 'org file', types ? '@system-types' : null],
['folder', 'Projects', b ? (Array.isArray(b.projects) && b.projects.length ? plural(b.projects.length, 'project') : 'No projects') : unread, 'org file', '#/projects'],
['people', 'Agents and roles', names ? (names.length ? `${plural(names.length, 'agent')} · ${Object.values(instances).filter(running).length} running` : 'No agents') : unread, 'org file', 'agents'],
['term', 'Models and routing', models ? (models.length ? models.join(' · ') : 'No models set') : unread, 'org file', 'routing'],
@@ -185,8 +186,10 @@
['close', 'Archive or delete', "Archive or delete this organization's file", 'danger', 'organizations'],
];
return `<ul class="ws-cfg one" data-org-settings>${rows.map(([ic, name, value, tag, page]) => {
const href = page === null ? null : page.startsWith('#') ? page : `#/settings/${page}`;
const go = href ? `<a class="go" href="${esc(href)}" data-focus-key="${esc(`set:${name}`)}" aria-label="Open ${esc(name)}">Open</a>` : '<span class="go" aria-hidden="true"></span>';
const href = page === null || page.startsWith('@') ? null : page.startsWith('#') ? page : `#/settings/${page}`;
// '@system-types' is the System types section further down this page, not a route.
const go = page === '@system-types' ? `<button type="button" class="go" data-system-types-open data-focus-key="${esc(`set:${name}`)}" aria-label="Open ${esc(name)}">Open</button>`
: href ? `<a class="go" href="${esc(href)}" data-focus-key="${esc(`set:${name}`)}" aria-label="Open ${esc(name)}">Open</a>` : '<span class="go" aria-hidden="true"></span>';
return `<li${tag === 'danger' ? ' class="danger"' : ''} data-org-setting="${esc(name)}">${icon(ic)}<span class="k">${esc(name)}</span><span class="v" title="${esc(value)}">${esc(value)}</span><span class="src-tag">${esc(tag)}</span>${go}</li>`;
}).join('')}</ul>`;
}
@@ -200,7 +203,8 @@
const list = Array.isArray(listRead) ? listRead : null;
const org = list?.find(o => o.id === id && !o.invalid);
if (!org) return `<article class="ws ws-dash" data-org-dashboard data-org-unavailable><div class="ws-vh"><h1 tabindex="-1">Organization unavailable</h1><p class="sub">${list ? 'This organization is not on your list.' : 'The organization list could not be read.'}</p></div></article>`;
const reads = await Promise.allSettled([data.business(id), data.settings(id), data.instances(id), data.threads(id), data.projects(id), data.queue(id), data.agents(id), data.claims(id)]);
const types = window.MosaicSystemTypes || null;
const reads = await Promise.allSettled([data.business(id), data.settings(id), data.instances(id), data.threads(id), data.projects(id), data.queue(id), data.agents(id), data.claims(id), types ? types.load(id) : null]);
const value = i => (reads[i].status === 'fulfilled' ? reads[i].value : null);
const business = value(0), settings = value(1), instances = instancesOf(value(2));
const threads = rowsOf(value(3)), projectRows = rowsOf(value(4)), queue = rowsOf(value(5));
@@ -220,7 +224,7 @@
const meta = `<p class="ws-meta"><span>Kind not recorded</span><span>id <code>${esc(id)}</code></span><span>Tracker: ${tracker ? esc(titleCase(tracker)) : 'not set'}</span><span>${dot(bcls, bglyph, org.busHealthy ? `bus running, read ${clock()}` : `bus unavailable, read ${clock()}`, true)}</span><span id="${esc(why)}">Restart is a trailed human action; reload arrives with the cross-org update.</span></p>`;
const projectName = pid => projectRows?.find(p => p.id === pid)?.name || pid;
const typechips = '<div class="ws-typechips ws-chips"><span class="ws-label">System types</span><span class="note">No system types yet. They arrive with the system types update.</span></div>';
const typechips = types ? types.chips(id) : '<div class="ws-typechips ws-chips"><span class="ws-label">System types</span><span class="note">System types unavailable</span></div>';
const docChips = !docRead ? '<span class="note">Docs unavailable</span>' : docRead.failed ? '<span class="note">Docs unavailable</span>' : docRead.rows.length ? docRead.rows.slice(0, 6).map(d => `<a class="ws-doc" href="#/docs/${enc(d.project)}/${enc(d.path)}" data-focus-key="${esc(`doc:${d.project}:${d.path}`)}" title="${esc(`${d.project}/${d.path}`)}">${icon('doc')}${esc(docName(d.path))}<small>${esc(projectName(d.project))}</small></a>`).join('') : '<span class="note">No docs yet</span>';
const docrow = `<div class="ws-docrow ws-chips"><span class="ws-label">Docs</span>${docChips}<a class="ws-more" href="#/docs" data-focus-key="docs-all">All docs</a></div>`;
@@ -258,7 +262,8 @@
</section>
<section aria-label="Settings">
${secH('Settings', 'every setting, with where its value comes from')}
${settingsList(org, { business, settings, instances, orgCount: list ? list.length : null })}
${settingsList(org, { business, settings, instances, orgCount: list ? list.length : null, types })}
${types ? types.section(id, { pane, idPrefix: opts.idPrefix || '' }) : ''}
</section>
</div>
</article>`;
+23 -9
View File
@@ -18,6 +18,7 @@ import { themesHTTP } from './themes-http.mjs';
import { DECISION_ROUTE, decisionHTTP, decisionRefuse, createConfirmations } from './decision-http.mjs';
import { REQUEST_ROUTE, requestHTTP, requestRefuse } from './request-http.mjs';
import { PROJECT_REMOTE_ROUTE, projectRemoteHTTP } from './project-remote-http.mjs';
import { SYSTEM_TYPES_ROUTE, systemTypesHTTP } from './system-types-http.mjs';
import { orgAdminHTTP, defaultOrgAdmin } from './org-admin.mjs';
import { consoleRestartHTTP } from './console-restart-http.mjs';
import { workspaceHTTP } from './workspace-read.mjs';
@@ -76,6 +77,9 @@ const files = new Map([
['/editor.css', ['editor.css', 'text/css; charset=utf-8']],
['/icons.svg', ['icons.svg', 'image/svg+xml']],
['/project-remote.css', ['project-remote.css', 'text/css; charset=utf-8']],
// Phase P2 (#1873): the owner's System types control (dashboard section and project selection).
['/system-types.js', ['system-types.js', 'text/javascript; charset=utf-8']],
['/system-types.css', ['system-types.css', 'text/css; charset=utf-8']],
...[400, 500, 600, 700].map(w => [`/assets/fonts/manrope-${w}.woff2`, [`assets/fonts/manrope-${w}.woff2`, 'font/woff2']]),
...[400, 500, 700].map(w => [`/assets/fonts/jetbrains-mono-${w}.woff2`, [`assets/fonts/jetbrains-mono-${w}.woff2`, 'font/woff2']]),
]);
@@ -507,7 +511,9 @@ const AGENT_ROUTES = {
};
// F3: the writes the owner makes on a selected organization's bus from Settings (dev org Console).
const ORG_OWNER_WRITE = path => path === '/api/settings' || path === '/api/instances' || path === '/api/routes'
|| Object.hasOwn(SEAT_ROUTES, path) || Object.hasOwn(AGENT_ROUTES, path) || path.startsWith('/api/proxy/');
|| Object.hasOwn(SEAT_ROUTES, path) || Object.hasOwn(AGENT_ROUTES, path) || path.startsWith('/api/proxy/')
// Phase P2 (#1873): the System types enable switch and a project's selection; the route keeps its own checks.
|| SYSTEM_TYPES_ROUTE.test(path);
async function agentSend(req, res, { route, agentWrite, preview, log, note = null }) {
let target = null;
// The instance or name is quoted and cut short so a crafted value can't forge a log line. A reason is never logged.
@@ -773,7 +779,7 @@ async function libraryStatusRead(res, read, search) {
// `log` takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
// tests; the field table is otherwise the committed packages/tasks/fields.json.
// `agentWrite` (row 194, D7) is `(verb, args) => Promise` for the Agents section's verbs and instance.request.
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null, processing = null, decisionWrite = null, requestRead = null, requestWrite = null, userOptions = null, federation = null, organizations = null, preselect = null, routes = [], orgAdmin = null, devAllowHosts = [], log = line => console.log(line), fieldsFile = undefined, restart = null, trail = null, boot = randomUUID(), workspaceTimeoutMs = undefined, agentLevels = null } = {}) {
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null, processing = null, decisionWrite = null, requestRead = null, requestWrite = null, systemTypesWrite = null, userOptions = null, federation = null, organizations = null, preselect = null, routes = [], orgAdmin = null, devAllowHosts = [], log = line => console.log(line), fieldsFile = undefined, restart = null, trail = null, boot = randomUUID(), workspaceTimeoutMs = undefined, agentLevels = null } = {}) {
// org-lan-bind: the dev owner on the LAN is an explicit opt-in. `devAllowHosts` names the Host
// names (without a port) a remote browser may use; only the dev owner takes them, and only with
// them may it bind a non-loopback IPv4 address. SSO, preview and tester listeners stay loopback.
@@ -796,6 +802,8 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
// #797 / #1839 Task 3: the request intake reads need --sso, the writes also --send; never the tester.
if ((requestRead !== null || requestWrite !== null) && (tester || sso === null || (requestRead !== null && typeof requestRead !== 'function') || (requestWrite !== null && (send === null || typeof requestWrite !== 'function'))))
throw new Error('Console requests need --sso (and --send for the writes) on the main listener');
// Phase P2 (#1873): the owner's System types writes need --sso and --send, never the tester.
if (systemTypesWrite !== null && (tester || sso === null || send === null || typeof systemTypesWrite !== 'function')) throw new Error('System types writes need --sso and --send on the main listener');
const confirmations = createConfirmations();
const registered = routeTable();
for (const registerRoutes of routes) registerRoutes(registered);
@@ -818,7 +826,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
// `userOptions` (FED R6, #1800) is `session => options`, the bus-backed options over that session
// user's own transport (console-bus.mjs). Only a signed-in listener has user sessions.
if (userOptions !== null && (sso === null || typeof userOptions !== 'function')) throw new Error('user sessions need --sso');
const owner = { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, upstream, files };
const owner = { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, systemTypesWrite, upstream, files };
const hub = federationView(federation);
// An object with none of the three is refused too: asking for the preview never serves without it.
if (preview !== null && !(preview = previewOptions({ publicHost: preview.host, authHeader: preview.header, allowUser: preview.user }))) {
@@ -832,7 +840,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
let boundAddress = null, boundPort = null;
const server = createServer(async (req, res) => {
// A granted user's request rebinds these below; every other request keeps the owner's.
let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, upstream, files } = owner, granted = false, chatGrants = [];
let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, systemTypesWrite, upstream, files } = owner, granted = false, chatGrants = [];
res.setHeader('x-content-type-options', 'nosniff');
res.setHeader('referrer-policy', 'no-referrer');
// Under --sso the Sign out form's POST is redirected to the provider's end-session endpoint, and
@@ -902,7 +910,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
// alone stays open on a loopback Host for the status and release gates (Q-C).
if (sso !== null && !named && !(path === '/healthz' && req.method === 'GET')) return json(res, 403, { error: 'public Host required' });
// No CORS. JSON content type and same-origin checks keep browser forms out.
const decisionPath = DECISION_ROUTE.test(path), requestPath = REQUEST_ROUTE.test(path), remotePath = PROJECT_REMOTE_ROUTE.test(path);
const decisionPath = DECISION_ROUTE.test(path), requestPath = REQUEST_ROUTE.test(path), remotePath = PROJECT_REMOTE_ROUTE.test(path), systemTypesPath = SYSTEM_TYPES_ROUTE.test(path);
if (req.headers.origin && req.headers.origin !== (named ? (sso ? (localOrigin ?? sso.publicOrigin) : `https://${preview.host}`) : `http://${req.headers.host}`))
return decisionPath ? decisionRefuse(res, 'cross-origin') : requestPath ? requestRefuse(res, 'cross-origin') : json(res, 403, { error: 'cross-origin request refused' });
let signer = preview, ssoSession = null;
@@ -914,7 +922,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (!session && path !== '/healthz') {
if (decisionPath) return decisionRefuse(res, 'sign-in-required');
if (requestPath) return requestRefuse(res, 'sign-in-required');
if (remotePath) return json(res, 401, { error: 'sign-in-required' });
if (remotePath || systemTypesPath) return json(res, 401, { error: 'sign-in-required' });
if (path.startsWith('/api/') || req.method !== 'GET') return json(res, 401, { error: 'sign-in required' });
res.writeHead(303, { location: `/auth/login?return=${encodeURIComponent(path + search)}`, 'cache-control': 'no-store' });
return res.end();
@@ -928,7 +936,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (!organizations && !userRoute(path, req.method)) return json(res, 403, { error: 'owner-only', message: 'this part of the Console is the business owner\'s' });
if (!organizations && userOptions === null) return json(res, 503, { error: 'not-configured', message: 'the Console has no user transport' });
if (!organizations) ({ bus, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing } = userOptions(session));
reads = null; upstream = null; libraryStatus = null; libraryRevert = null; files = USER_FILES; granted = true; proxyCall = null;
reads = null; upstream = null; libraryStatus = null; libraryRevert = null; files = USER_FILES; granted = true; proxyCall = null; systemTypesWrite = null;
chatGrants = sso.chatGrants?.(session) ?? [];
}
}
@@ -959,7 +967,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
({ bus = null, reads = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null,
projectSave = null, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null,
assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null,
processing = null, decisionWrite = null, requestRead = null, requestWrite = null, upstream = null } = selected);
processing = null, decisionWrite = null, requestRead = null, requestWrite = null, systemTypesWrite = null, upstream = null } = selected);
}
// F3: the owner's settings writes reach the selected organization's bus too (limits, routing,
// agents, assistant, instances, model proxy). The routes below keep their own checks.
@@ -1016,7 +1024,13 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
return projectRemoteHTTP(req, res, { path, read: requestRead, write: send ? requestWrite : null,
session: ssoSession, live: () => sso.session(req), origin: localOrigin ?? sso.publicOrigin });
}
if (tester && ['/project-remote.js', '/project-remote.css', '/files.js', '/files.css'].includes(path)) return json(res, 403, { error: 'owner-only' });
// Phase P2 (#1873): the owner's System types read and writes on the selected organization.
if (systemTypesPath) {
if (tester || granted || !named || sso === null) return json(res, 403, { error: 'owner-only' });
return systemTypesHTTP(req, res, { path, read: requestRead, write: send ? systemTypesWrite : null,
session: ssoSession, live: () => sso.session(req), origin: localOrigin ?? sso.publicOrigin });
}
if (tester && ['/project-remote.js', '/project-remote.css', '/files.js', '/files.css', '/system-types.js', '/system-types.css'].includes(path)) return json(res, 403, { error: 'owner-only' });
// Row 194 (#1692, D4.1): the tester also launches and terminates.
if (tester && !['GET', 'HEAD'].includes(req.method) && !['/api/instances', '/api/routes', '/api/name', '/api/agent/launch', '/api/agent/terminate'].includes(path) && !TESTER_CHAT_WRITE.test(path))
return json(res, 403, { error: 'read-only', sent: false });
+140
View File
@@ -0,0 +1,140 @@
// Phase P2 (#1873): the owner's System types routes on a selected organization. Owner Console
// transports only; no caller-supplied business, actor or subject reaches the bus.
//
// GET /api/system-types installed modules (label, description) and the enabled set,
// plus the plugin revision when writes are on
// POST /api/system-types {revision, enabled}: plugins.set {'system-type': enabled},
// the organization writer's owner fence, revision and receipt
// GET /api/system-types/projects/<id> the same, with the project's selection, active and inactive
// POST /api/system-types/projects/<id> {systemTypes}: project.system-types.set
//
// `read` is the owner's Console call (consoleCall): it reaches only `system-types.console`, which the
// broker answers to the business human's console cap. `write` is the owner's human transport limited
// to SYSTEM_TYPE_VERBS, present only for the owner's own session under --send.
export const SYSTEM_TYPES_ROUTE = /^\/api\/system-types(?:\/projects\/([^/]+))?$/;
export const SYSTEM_TYPE_VERBS = Object.freeze(['plugins', 'plugins.set', 'project.system-types.set']);
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/;
// A module name, as plugins/src/kinds.mjs NAME.
const NAME = /^[a-z][a-z0-9-]{0,63}$/;
const REVISION = /^[a-f0-9]{64}$/;
const TRANSACTION = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const MAX_TYPES = 64;
const one = (req, key) => req.headersDistinct[key]?.length === 1 ? req.headersDistinct[key][0] : null;
const codes = {
'invalid-request': 400, 'invalid-plugins': 400, 'unknown-plugin': 400, 'system-type-unknown': 400,
'project-not-found': 404, 'revision-conflict': 409, 'organization-busy': 409, 'system-type-not-enabled': 409,
'human-required': 403, 'organization-system-types-human-only': 403, 'read-only': 403,
'not-configured': 503, 'system-type-invalid': 503, 'no-bus-host': 503,
};
function json(res, status, value) {
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
res.end(JSON.stringify(value));
}
const names = list => Array.isArray(list) && list.length <= MAX_TYPES && list.every(x => typeof x === 'string' && NAME.test(x)) && new Set(list).size === list.length;
const text = value => typeof value === 'string' && value.trim() !== '' && value.length <= 400 && !/[\r\n]/.test(value);
function consoleShape(result, project) {
if (!result || !Array.isArray(result.installed) || !names(result.enabled)) return null;
const installed = [];
for (const m of result.installed) {
if (!m || !NAME.test(m.id ?? '') || !text(m.label) || !text(m.description)) return null;
installed.push({ id: m.id, label: m.label, description: m.description });
}
if (new Set(installed.map(m => m.id)).size !== installed.length) return null;
let selection = null;
if (project !== null) {
const p = result.project;
if (!p || p.project !== project || !names(p.selected) || !names(p.active) || !names(p.inactive)) return null;
selection = { project, selected: p.selected, active: p.active, inactive: p.inactive };
} else if (result.project !== null) return null;
return { installed, enabled: result.enabled, project: selection };
}
const selectionShape = (r, project) => r && r.project === project && names(r.selected) && names(r.active) && names(r.inactive)
? { project, selected: r.selected, active: r.active, inactive: r.inactive } : null;
async function body(req, keys) {
if (one(req, 'content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') return null;
const chunks = []; let size = 0;
for await (const chunk of req) { size += chunk.length; if (size > 4096) return 'too-large'; chunks.push(chunk); }
let value;
try { value = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { return null; }
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length !== keys.length || !keys.every(k => Object.hasOwn(value, k))) return null;
return value;
}
export async function systemTypesHTTP(req, res, { path, read, write, session, live, origin }) {
const refuse = (status, error) => json(res, status, { error });
const fail = error => { const code = codes[error?.code] ? error.code : 'outcome-unknown'; return refuse(codes[code] ?? 503, code); };
if (!['GET', 'POST'].includes(req.method)) return refuse(405, 'method-not-allowed');
if (!session) return refuse(401, 'sign-in-required');
if (!read) return refuse(503, 'not-configured');
if (req.method === 'POST' && !write) return refuse(403, 'read-only');
const site = one(req, 'sec-fetch-site'), from = one(req, 'origin');
if (req.method === 'POST' ? from !== origin || site !== 'same-origin'
: (req.headersDistinct.origin && from !== origin) || (req.headersDistinct['sec-fetch-site'] && !['same-origin', 'none'].includes(site)))
return refuse(403, 'cross-origin');
let project = null;
const match = SYSTEM_TYPES_ROUTE.exec(path);
if (match?.[1] !== undefined) {
try { project = decodeURIComponent(match[1]); } catch { return refuse(404, 'project-not-found'); }
if (!ID.test(project)) return refuse(404, 'project-not-found');
}
let args = null;
if (req.method === 'POST') {
const value = await body(req, project === null ? ['revision', 'enabled'] : ['systemTypes']);
if (value === 'too-large') return refuse(413, 'invalid-request');
if (!value) return refuse(400, 'invalid-request');
if (project === null ? typeof value.revision !== 'string' || !REVISION.test(value.revision) || !names(value.enabled) : !names(value.systemTypes))
return refuse(400, 'invalid-request');
args = value;
}
if (live() !== session) return refuse(401, 'sign-in-required');
const subject = { provider: session.provider, iss: session.iss, sub: session.sub };
// The read comes first on every request: a write names only modules this organization has installed.
let current;
try { current = consoleShape(await read('system-types.console', project === null ? {} : { project }, { subject }), project); }
catch (error) { return fail(error); }
if (!current) return refuse(503, 'outcome-unknown');
const installed = new Set(current.installed.map(m => m.id));
if (req.method === 'GET') {
let revision = null;
if (write && project === null) {
// The enabled set and its revision come from one plugins read. The page sends back a whole
// replacement list, so a list older than its revision would undo a change made in between.
let plugins;
try { plugins = await write('plugins', {}); } catch (error) { return fail(error); }
revision = plugins?.revision ?? null;
const enabled = plugins?.plugins?.['system-type'] ?? [];
if (!REVISION.test(revision ?? '') || !names(enabled)) return refuse(503, 'outcome-unknown');
current.enabled = enabled;
}
if (live() !== session) return refuse(401, 'sign-in-required');
return json(res, 200, { ...current, writable: Boolean(write), ...(project === null && write ? { revision } : {}) });
}
// The session is checked again after the read: a sign-out while it ran dispatches no write.
if (live() !== session) return refuse(401, 'sign-in-required');
if (project === null) {
if (args.enabled.some(id => !installed.has(id))) return refuse(400, 'system-type-unknown');
let result;
try { result = await write('plugins.set', { revision: args.revision, plugins: { 'system-type': args.enabled } }); }
catch (error) { return fail(error); }
// A write admitted at spawn may already have committed: its receipt is returned even if the
// session ended meanwhile, never reported as not done.
const enabled = result?.plugins?.['system-type'] ?? [];
if (!result || !REVISION.test(result.revision ?? '') || !TRANSACTION.test(result.transaction ?? '') || !names(enabled)
|| JSON.stringify(enabled) !== JSON.stringify(args.enabled))
return refuse(503, 'outcome-unknown');
return json(res, 200, { enabled, revision: result.revision, transaction: result.transaction });
}
// The broker refuses a type the organization has not enabled (system-type-not-enabled); a type that
// is not installed is refused here first.
if (args.systemTypes.some(id => !installed.has(id))) return refuse(400, 'system-type-unknown');
let result;
try { result = selectionShape(await write('project.system-types.set', { project, systemTypes: args.systemTypes }), project); }
catch (error) { return fail(error); }
if (!result || JSON.stringify(result.selected) !== JSON.stringify(args.systemTypes)) return refuse(503, 'outcome-unknown');
return json(res, 200, result);
}
@@ -88,6 +88,6 @@ test('a refused root file hides the root level only, and says why without a host
writeFileSync(join(dir, 'agents.json'), JSON.stringify({ agentsVersion: 1, agents: { x: { definition: 'reviewer', vars: { thinking: 'sk-secret-marker-7731' } } } }));
const badVar = await call({ owner: true, session: {}, organizations: { members }, levels: { ...levels, loadRoot: () => loadRootAgents(dir, { rolesDir }) } });
assert.equal(badVar.body.root.state, 'invalid');
assert.match(badVar.body.root.reason, /variable thinking has a value it can't take/);
assert.match(badVar.body.root.reason, /variable thinking must be one of/);
assert.doesNotMatch(JSON.stringify(badVar.body), /secret-marker/);
});
+2 -2
View File
@@ -18,8 +18,8 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
assert.equal(await b.evaluate('document.querySelector("#session-count").textContent'), '3 of 4');
await b.evaluate('document.querySelector("#hide-offline").click()');
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4);
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js, and F3 adds console-restart.js after it. U2 (#1873) adds workspace-data.js and workspace.js before console-restart.js, and U4b adds tasks-board.js after workspace.js. U5 (#1873) adds panes.js (Side by side) just before bus.js, which mounts it.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/workspace-data.js', '/workspace.js', '/tasks-board.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/panes.js', '/bus.js', '/requests.js']);
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js, and F3 adds console-restart.js after it. U2 (#1873) adds workspace-data.js and workspace.js before console-restart.js, and U4b adds tasks-board.js after workspace.js. U5 (#1873) adds panes.js (Side by side) just before bus.js, which mounts it. P2 (#1873) adds system-types.js after project-remote.js.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/workspace-data.js', '/workspace.js', '/tasks-board.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/system-types.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/panes.js', '/bus.js', '/requests.js']);
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/processing.js"]').length`), 1);
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/chat.js"]').length`), 1);
assert.equal(await b.evaluate('!!window.injected'), false);
@@ -0,0 +1,42 @@
import { createHash, randomUUID } from 'node:crypto';
import { installedSystemTypes } from '../../../plugins/src/system-types.mjs';
// Phase P2 (#1873): a per-organization stand-in for the two transports the System types routes use,
// shaped as the broker answers them. `requestRead` answers system-types.console with the installed
// modules (the plug-in data itself, read from packages/plugins), the enabled set and a project's
// selection. `systemTypesWrite` answers plugins, plugins.set (revision checked, a transaction
// returned) and project.system-types.set (enabled types only). `owner: false` makes plugins.set
// refuse as the organization writer's owner fence does for anyone but the human.
export async function systemTypesFake({ enabled = [], projects = {}, owner = true } = {}) {
const installed = await installedSystemTypes();
const state = { enabled: [...enabled], projects: structuredClone(projects), n: 0, writes: [] };
const revision = () => createHash('sha256').update(`system-types-${state.n}`).digest('hex');
const fail = code => { throw Object.assign(new Error(code), { code }); };
const selection = project => {
const selected = state.projects[project];
return { project, selected: [...selected], active: selected.filter(x => state.enabled.includes(x)), inactive: selected.filter(x => !state.enabled.includes(x)) };
};
const requestRead = async (verb, args) => {
if (verb !== 'system-types.console') fail('invalid-request');
if (args.project !== undefined && !Object.hasOwn(state.projects, args.project)) fail('project-not-found');
return { installed: structuredClone(installed), enabled: [...state.enabled], project: args.project === undefined ? null : selection(args.project) };
};
const systemTypesWrite = async (verb, args) => {
state.writes.push(verb);
if (verb === 'plugins') return { business: 'fake', revision: revision(), plugins: { 'system-type': [...state.enabled] } };
if (verb === 'plugins.set') {
if (args.revision !== revision()) fail('revision-conflict');
if (!owner) fail('organization-system-types-human-only');
state.enabled = [...args.plugins['system-type']]; state.n++;
return { business: 'fake', revision: revision(), plugins: { 'system-type': [...state.enabled] }, transaction: randomUUID() };
}
if (verb === 'project.system-types.set') {
if (!Object.hasOwn(state.projects, args.project)) fail('project-not-found');
if (args.systemTypes.some(x => !state.enabled.includes(x))) fail('system-type-not-enabled');
state.projects[args.project] = [...args.systemTypes];
return selection(args.project);
}
fail('invalid-request');
};
return { requestRead, systemTypesWrite, state, installed };
}
@@ -0,0 +1,244 @@
// Phase P2 (#1873): System types on the organization dashboard (sample 03) and on a project's information
// page. The Console, its per-organization routes and the pages are real; only the two bus transports are
// the fake in helpers/system-types-fake.mjs, which reads the installed modules from packages/plugins. The
// server-side admission checks and the real organization writer are in system-types-http.test.mjs.
// MOSAIC_UX_SHOTS=<dir> also writes light and dark shots at 1440x900 and 390x844.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
import { systemTypesFake } from './helpers/system-types-fake.mjs';
const P = '10000000-0000-4000-8000-0000000000b1';
const RECEIPT = /Receipt ([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\./;
const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<300;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)}+' | '+(document.querySelector('main')?.innerText||'').slice(0,1500))})()`);
// One running organization with one project. `send` is the owner's signed-in send mode; without it the
// Console only reads and the route gets no writer.
function organizations(st, { send = true } = {}) {
const business = { business: { id: 'acme', human: 'jason', arbiters: [], projects: [P], vars: {}, instances: [], appearance: { theme: null } } };
return {
list: async () => [{ id: 'acme', label: 'Acme', theme: null, busHealthy: true }],
resolve: async id => id !== 'acme' ? null : ({
busHealthy: true, requestRead: st.requestRead, systemTypesWrite: send ? st.systemTypesWrite : null, ...(send ? { send: async () => ({}) } : {}),
reads: { business: async () => business, settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
bus: { instances: async () => ({ business: 'acme', revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {}, instances: {} }),
messages: async () => ({ messages: [], unread: 0 }), agents: async () => [], 'thread.claims': async () => [], inbox: async () => [], tasks: async () => [] },
chat: { projects: async () => [{ id: P, name: 'Work', repo: null }], threads: async () => [] },
}),
};
}
async function page(t, st, { send = true, width = 1440, height = 900 } = {}) {
const server = await startServer({ port: 0, sso: createDevOwner(), ...(send ? { send: async () => ({}) } : {}), organizations: organizations(st, { send }), log: () => {} });
t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
const errors = [];
b.on('Runtime.exceptionThrown', p => errors.push(p.exceptionDetails?.exception?.description ?? p.exceptionDetails?.text ?? 'exception'));
await b.viewport(width, height);
const base = `http://127.0.0.1:${server.address().port}/`;
return { b, base, wait: waitFor(b), errors };
}
const dashboard = async ({ b, base, wait }) => {
await b.navigate(`${base}#/orgs/acme/organization`);
await wait('document.querySelector("[data-org-dashboard] [data-system-types]") && !/Reading/.test(document.querySelector("[data-system-types]").textContent)');
};
const settings = async ({ b, base, wait }) => {
await b.navigate(`${base}#/orgs/acme/projects/${P}/settings`);
await wait('document.querySelector("[data-project-system-types]") && !/Reading system types/.test(document.querySelector("[data-project-system-types]").textContent)');
};
const text = (b, selector) => b.evaluate(`(document.querySelector(${JSON.stringify(selector)})?.innerText ?? '').replace(/\\s+/g, ' ').trim()`);
const chips = b => b.evaluate(`[...document.querySelectorAll('[data-system-type-chips] .ws-chip')].map(c => c.textContent)`);
const click = (b, selector) => b.evaluate(`(document.querySelector(${JSON.stringify(selector)}).click(), true)`);
test('the dashboard lists the installed modules from the plug-in data and the owner switches one with a receipt', { timeout: 60000 }, async t => {
const st = await systemTypesFake({ enabled: ['coding'], projects: { [P]: [] } });
const p = await page(t, st); const { b, wait } = p;
await dashboard(p);
// Every row is a module the plug-ins declare, in their order, with their label and description.
const rows = await b.evaluate(`[...document.querySelectorAll('[data-system-types] li')].map(li => [li.dataset.systemType, li.querySelector('.t b').textContent, li.querySelector('.t .d').textContent, li.querySelector('.ws-dot').textContent.replace(/[●–]/, '')])`);
assert.deepEqual(rows, st.installed.map(m => [m.id, m.label, m.description, m.id === 'coding' ? 'enabled' : 'off']));
assert.ok(st.installed.length >= 2, 'the tree installs more than one system type module');
assert.deepEqual(await chips(b), ['Coding']);
assert.match(await text(b, '[data-system-type-chips]'), /Each project picks from these; none is required\.$/);
assert.match(await text(b, '[data-org-setting="System types"]'), new RegExp(`1 of ${st.installed.length} installed enabled`));
// The Settings row's Open moves focus to the section heading.
await click(b, '[data-org-setting="System types"] [data-system-types-open]');
assert.equal(await b.evaluate('document.activeElement?.dataset.systemTypesHeading'), '');
// Enable Marketing from the keyboard; the receipt is the organization writer's transaction.
await b.evaluate(`document.querySelector('[data-system-type-toggle="marketing"]').focus(), true`);
assert.equal(await b.evaluate(`document.activeElement.getAttribute('aria-label')`), 'Enable Marketing');
await b.key('Enter');
await wait(`/Receipt/.test(document.querySelector('[data-system-types-status]')?.textContent)`);
assert.match(await text(b, '[data-system-types-status]'), new RegExp(`^Marketing enabled\\. ${RECEIPT.source}$`));
assert.deepEqual(st.state.enabled, ['coding', 'marketing']);
assert.deepEqual(await chips(b), ['Coding', 'Marketing']);
assert.match(await text(b, '[data-org-setting="System types"]'), new RegExp(`2 of ${st.installed.length} installed enabled`));
// Focus stays on the switch it pressed, which now says Disable.
assert.deepEqual(await b.evaluate(`[document.activeElement.dataset.focusKey, document.activeElement.getAttribute('aria-label')]`), ['st:marketing', 'Disable Marketing']);
// Disable Coding; another write changed the organization file meanwhile, so the first try is refused.
st.state.n++;
await click(b, '[data-system-type-toggle="coding"]');
await wait(`/changed after this page read it/.test(document.querySelector('[data-system-types-status]')?.textContent)`);
assert.deepEqual(st.state.enabled, ['coding', 'marketing']);
await click(b, '[data-system-type-toggle="coding"]');
await wait(`/Coding disabled/.test(document.querySelector('[data-system-types-status]')?.textContent)`);
assert.match(await text(b, '[data-system-types-status]'), RECEIPT);
assert.deepEqual(st.state.enabled, ['marketing']);
assert.equal(await b.evaluate(`document.querySelector('[data-system-type="coding"] .ws-dot').textContent.replace(/[●–]/, '')`), 'off');
assert.deepEqual(p.errors, []);
});
test('without the owner proof the switch is refused, and a read-only Console offers none', { timeout: 60000 }, async t => {
// The writer's owner fence refuses: the page says so and nothing changes.
const fenced = await systemTypesFake({ enabled: ['coding'], projects: { [P]: [] }, owner: false });
const p = await page(t, fenced); const { b, wait } = p;
await dashboard(p);
await click(b, '[data-system-type-toggle="marketing"]');
await wait(`/owner can change/.test(document.querySelector('[data-system-types-status]')?.textContent)`);
assert.equal(await text(b, '[data-system-types-status]'), "Only the organization's owner can change system types.");
assert.deepEqual(fenced.state.enabled, ['coding']);
assert.doesNotMatch(await text(b, '[data-system-types]'), /Receipt/);
// A Console without send mode reads the list and shows no switch; no write reaches the transport.
const readOnly = await systemTypesFake({ enabled: ['coding'], projects: { [P]: ['coding'] } });
const q = await page(t, readOnly, { send: false });
await dashboard(q);
assert.equal(await q.b.evaluate(`document.querySelectorAll('[data-system-types] li').length`), readOnly.installed.length);
assert.equal(await q.b.evaluate(`document.querySelectorAll('[data-system-type-toggle]').length`), 0);
assert.match(await text(q.b, '[data-system-types]'), /This Console only reads\. Changing system types needs signed-in send mode\./);
// The project page shows the selection read only too.
await settings(q);
assert.equal(await q.b.evaluate(`[...document.querySelectorAll('[data-project-system-types] input')].every(i => i.disabled)`), true);
assert.equal(await q.b.evaluate(`document.querySelector('[data-project-system-types] button[type=submit]')`), null);
assert.deepEqual(readOnly.state.writes, []);
});
test('a project selects only enabled modules and a disabled selection shows as inactive', { timeout: 60000 }, async t => {
// Montage was selected, then disabled in the organization.
const st = await systemTypesFake({ enabled: ['coding', 'marketing'], projects: { [P]: ['montage'] } });
const p = await page(t, st); const { b, wait } = p;
await settings(p);
const label = id => st.installed.find(m => m.id === id).label;
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-project-system-types] input[name=system-type]')].map(i => [i.value, i.checked, i.closest('label').querySelector('strong').textContent])`),
[['coding', false, label('coding')], ['marketing', false, label('marketing')]]);
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-inactive-type]')].map(li => [li.dataset.inactiveType, li.innerText.replace(/\\s+/g, ' ').trim()])`), [['montage', `${label('montage')} inactive`]]);
assert.match(await text(b, '[data-project-system-types-inactive]'), /Saving keeps only enabled types/);
// Pick Marketing and save: the inactive one goes, the saved selection is the bus's answer.
await b.evaluate(`document.querySelector('[data-project-system-types] input[value=marketing]').click(), true`);
await click(b, '[data-project-system-types] button[type=submit]');
await wait(`/Saved/.test(document.querySelector('[data-project-system-types-status]')?.textContent)`);
assert.equal(await text(b, '[data-project-system-types-status]'), `Saved. Selected: ${label('marketing')}.`);
assert.equal(await b.evaluate(`document.activeElement?.hasAttribute('data-project-system-types-status')`), true);
assert.deepEqual(st.state.projects[P], ['marketing']);
assert.equal(await b.evaluate(`document.querySelector('[data-inactive-type]')`), null);
// The organization disables Coding after the page read it; saving it is refused and the list is read again.
await b.evaluate(`document.querySelector('[data-project-system-types] input[value=coding]').click(), true`);
st.state.enabled = ['marketing'];
await click(b, '[data-project-system-types] button[type=submit]');
await wait(`/not enabled in the organization/.test(document.querySelector('[data-project-system-types-status]')?.textContent)`);
assert.deepEqual(st.state.projects[P], ['marketing']);
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-project-system-types] input[name=system-type]')].map(i => i.value)`), ['marketing']);
// The owner disables Marketing on the dashboard; back on the project it stays selected, shown inactive.
await dashboard(p);
await click(b, '[data-system-type-toggle="marketing"]');
await wait(`/Marketing disabled/.test(document.querySelector('[data-system-types-status]')?.textContent)`);
assert.deepEqual(st.state.enabled, []);
await settings(p);
assert.match(await text(b, '[data-project-system-types]'), /The organization has no system types enabled\./);
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-inactive-type]')].map(li => li.dataset.inactiveType)`), ['marketing']);
assert.equal(await b.evaluate(`document.querySelectorAll('[data-project-system-types] input').length`), 0);
// With nothing enabled the owner can still clear the inactive selection, without enabling a type first.
assert.equal(await text(b, '[data-project-system-types] button[type=submit]'), 'Clear the inactive types');
await click(b, '[data-project-system-types] button[type=submit]');
await wait(`/Saved/.test(document.querySelector('[data-project-system-types-status]')?.textContent)`);
assert.equal(await text(b, '[data-project-system-types-status]'), 'Saved. No system types selected.');
assert.deepEqual(st.state.projects[P], []);
assert.deepEqual(st.state.enabled, []);
assert.equal(await b.evaluate(`document.querySelector('[data-inactive-type]')`), null);
assert.equal(await b.evaluate(`document.querySelector('[data-project-system-types] button[type=submit]')`), null);
assert.deepEqual(p.errors, []);
// A read-only Console shows the same inactive selection and offers no clear button.
const readOnly = await systemTypesFake({ enabled: [], projects: { [P]: ['coding'] } });
const q = await page(t, readOnly, { send: false });
await settings(q);
assert.deepEqual(await q.b.evaluate(`[...document.querySelectorAll('[data-inactive-type]')].map(li => li.dataset.inactiveType)`), ['coding']);
assert.equal(await q.b.evaluate(`document.querySelector('[data-project-system-types] button[type=submit]')`), null);
assert.deepEqual(readOnly.state.writes, []);
});
test('the dashboard section and the project page fit a phone screen in light and dark', { timeout: 90000 }, async t => {
const st = await systemTypesFake({ enabled: ['coding', 'marketing'], projects: { [P]: ['coding', 'montage'] } });
const shots = process.env.MOSAIC_UX_SHOTS;
if (shots) mkdirSync(shots, { recursive: true });
for (const [width, height] of [[1440, 900], [390, 844]]) {
const p = await page(t, st, { width, height }); const { b } = p;
for (const mode of ['light', 'dark']) {
await b.call('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: mode }] });
await dashboard(p);
// The default appearance is Harbor in the dark whatever the OS says; the header switch picks the mode.
if (await b.evaluate(`document.getElementById('theme-switch').getAttribute('aria-checked')`) !== String(mode === 'dark')) {
await b.evaluate(`document.getElementById('theme-switch').click(), true`);
await p.wait(`document.getElementById('theme-switch').getAttribute('aria-checked') === '${mode === 'dark'}'`);
}
await b.evaluate(`document.querySelector('[data-system-types-heading]').scrollIntoView({ block: 'start' }), true`);
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true, `dashboard ${width} ${mode}`);
assert.equal(await b.evaluate(`[...document.querySelectorAll('[data-system-types] li')].every(li => li.scrollWidth <= li.clientWidth)`), true, `dashboard rows ${width} ${mode}`);
if (shots) await b.screenshot(join(shots, `p2-dashboard-types-${width}-${mode}.png`));
await settings(p);
await b.evaluate(`document.querySelector('[data-project-system-types]').scrollIntoView({ block: 'start' }), true`);
// At 1440 a project page taller than the window already overflows by its header's primary action
// (projects.css, before P2), so the wide check is the section's own box; the phone check is the page.
if (width === 390) assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true, `project ${width} ${mode}`);
assert.equal(await b.evaluate(`(s => s.scrollWidth <= s.clientWidth && s.getBoundingClientRect().right <= document.documentElement.clientWidth)(document.querySelector('[data-project-system-types]'))`), true, `project section ${width} ${mode}`);
assert.equal(await b.evaluate(`[...document.querySelectorAll('[data-project-system-types] .project-types-option')].every(o => o.getBoundingClientRect().height >= 44)`), true, `targets ${width} ${mode}`);
if (shots) await b.screenshot(join(shots, `p2-project-types-${width}-${mode}.png`));
}
assert.deepEqual(p.errors, []);
}
});
// U5's Side by side draws an organization dashboard into a pane. Each pane reads its own organization's
// system types; a pane only shows them (the switch stays in the organization's full view, as the bus
// restart does), and its Settings row opens the section in the same pane.
test('a side-by-side pane shows its own organization\'s system types and offers no switch', { timeout: 60000 }, async t => {
const acme = await systemTypesFake({ enabled: ['coding'], projects: { [P]: [] } });
const beta = await systemTypesFake({ enabled: ['marketing'], projects: {} });
const one = organizations(acme);
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), log: () => {}, organizations: {
list: async () => [{ id: 'acme', label: 'Acme', theme: null, busHealthy: true }, { id: 'beta', label: 'Beta', theme: null, busHealthy: true }],
resolve: async id => id === 'acme' ? one.resolve('acme') : id === 'beta' ? { ...(await one.resolve('acme')), requestRead: beta.requestRead, systemTypesWrite: beta.systemTypesWrite,
chat: { projects: async () => [], threads: async () => [] } } : null,
} });
t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
const errors = [];
b.on('Runtime.exceptionThrown', p => errors.push(p.exceptionDetails?.exception?.description ?? p.exceptionDetails?.text ?? 'exception'));
await b.viewport(1440, 900);
const wait = waitFor(b);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/acme/side-by-side?a=acme/organization&b=beta/organization`);
const ready = key => `document.querySelector('#pane-${key} [data-system-types]') && !/Reading/.test(document.querySelector('#pane-${key} [data-system-types]').textContent)`;
await wait(`${ready('a')} && ${ready('b')}`);
const paneChips = key => b.evaluate(`[...document.querySelectorAll('#pane-${key} [data-system-type-chips] .ws-chip')].map(c => c.textContent)`);
assert.deepEqual(await paneChips('a'), ['Coding']);
assert.deepEqual(await paneChips('b'), ['Marketing']);
assert.equal(await b.evaluate(`document.querySelectorAll('.panes [data-system-type-toggle]').length`), 0);
for (const key of ['a', 'b'])
assert.match(await text(b, `#pane-${key} [data-system-types]`), /Change system types from this organization's full view\./);
const ids = await b.evaluate(`[...document.querySelectorAll('[data-system-types-heading]')].map(h => h.id)`);
assert.equal(ids.length, 2); assert.equal(new Set(ids).size, 2);
await click(b, '#pane-b [data-org-setting="System types"] [data-system-types-open]');
assert.equal(await b.evaluate(`document.activeElement === document.querySelector('#pane-b [data-system-types-heading]')`), true);
assert.deepEqual([acme.state.writes.includes('plugins.set'), beta.state.writes.includes('plugins.set')], [false, false]);
assert.deepEqual(errors, []);
});
@@ -0,0 +1,235 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
import { request } from 'node:http';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { resolveInstance } from '../../organization/src/resolve.mjs';
import { busOrganization } from '../../bus/src/organization-context.mjs';
import { startBroker } from '../../bus/src/runtime.mjs';
import { serve } from '../../bus/src/server.mjs';
import { Client } from '../../bus/src/client.mjs';
import { installedSystemTypes } from '../../plugins/src/system-types.mjs';
import { startServer } from '../src/serve.mjs';
import { close } from './fixture.mjs';
// Phase P2 (#1873): the owner's System types routes on a selected organization, end to end. An
// ephemeral organization with the real runtime writer (plugins.set, the system types owner fence,
// changes.jsonl) and the broker's console socket for the read; the SSO session is a fixture
// authority. `writer` picks the cap the write transport binds: the owner's human cap, or the tester.
async function fixture(t, { writer = 'owner', send = true, afterRead = null, beforeWrite = null } = {}) {
// Cleanups run last-made first, registered as soon as each resource exists.
const undo = []; t.after(async () => { for (const step of undo.reverse()) await step(); });
const root = mkdtempSync(join(tmpdir(), 'st-'));
const rolesDir = rolesCopy(root), system = systemFor(root);
const doc = businessDoc(root, 'demo');
doc.plugins = { ...(doc.plugins ?? {}), 'system-type': ['coding'], harness: ['pi'] };
doc.vars = { ...doc.vars, 'bus.tester': true };
const file = writeJson(join(root, 'businesses/demo.json'), doc);
mkdirSync(join(root, 'data'), { mode: 0o700 });
const business = loadBusiness('demo', { dir: root, rolesDir });
const resolved = Object.fromEntries(Object.keys(business.roles).map(instance => [instance, resolveInstance({ system, business, instance })]));
undo.push(() => rmSync(root, { recursive: true, force: true }));
const runtime = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: busOrganization(business, resolved) }, organization: { dir: root, rolesDir, system } });
undo.push(() => runtime.close());
const path = join(root, 'b.sock');
const socketServer = await serve({ broker: runtime.broker, path, peerReader: { read: async () => ({}), close: () => {} },
authenticateConsole: proof => runtime.broker.bindConsole({ business: 'demo', human: 'jason', subject: proof.subject }) });
undo.push(() => socketServer.close());
const owner = runtime.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const role = Object.keys(business.roles)[0];
const cap = writer === 'tester' ? runtime.broker.bindTester({ business: 'demo' })
: writer === 'agent' ? runtime.broker.bindLaunch({ business: 'demo', role, run: 'agent-run', harness: 'pi' }) : owner;
const calls = [];
// afterRead and beforeWrite let a test act between the route's read and its write.
const requestRead = async (verb, args, ctx) => { calls.push(['read', verb]); const r = await new Client({ path, console: { subject: ctx.subject } }).call(verb, args); await afterRead?.(); return r; };
const systemTypesWrite = async (verb, args) => { await beforeWrite?.(verb); calls.push(['write', verb]); return runtime.broker.request(cap, { verb, args }); };
const session = { provider: 'fixture', iss: 'https://issuer.example', sub: 'owner', name: 'jason' };
let active = true;
const sso = { publicOrigin: '', publicHost: '', formTargets: () => [], session: () => active ? session : null, handle: () => {}, health: () => ({}) };
const organizations = {
list: async () => [{ id: 'demo', label: 'Demo', busHealthy: true }],
resolve: async id => id !== 'demo' ? null : { busHealthy: true, send: send ? async () => ({}) : null, requestRead, systemTypesWrite: send ? systemTypesWrite : null,
reads: { business: async () => ({ business: { id: 'demo' } }) }, bus: {}, chat: { projects: async () => [], threads: async () => [] } },
};
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, send: async () => ({}), organizations, log: () => {} });
const base = `http://127.0.0.1:${web.address().port}`;
sso.publicOrigin = base; sso.publicHost = new URL(base).host;
undo.push(() => close(web));
const project = runtime.broker.request(owner, { verb: 'project.create', args: { name: 'example' } }).id;
const onDisk = () => JSON.parse(readFileSync(file)).plugins['system-type'];
const ownerRequest = (verb, args) => runtime.broker.request(owner, { verb, args });
return { base, file, project, calls, onDisk, ownerRequest, signOut: () => { active = false; } };
}
function call(f, path, method = 'GET', body, headers = {}) {
return new Promise((resolve, reject) => {
const data = body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body);
const req = request(f.base + path, { method, headers: { ...(data !== undefined ? { origin: f.base, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } : {}), ...headers } }, res => {
let text = ''; res.on('data', chunk => { text += chunk; }); res.on('end', () => resolve([res.statusCode, JSON.parse(text)]));
});
req.on('error', reject); req.setTimeout(10000, () => req.destroy(new Error('fixture request timed out'))); req.end(data);
});
}
const ORG = '/api/orgs/demo/system-types';
test('the owner reads the installed modules and enables one with the revision, getting a receipt', async t => {
const f = await fixture(t);
const [status, before] = await call(f, ORG);
assert.equal(status, 200, JSON.stringify(before));
// Labels and descriptions are the modules' own, never a list in the WebUI.
const modules = (await installedSystemTypes()).map(({ id, label, description }) => ({ id, label, description }));
assert.deepEqual(before.installed, modules);
assert.deepEqual(before.enabled, ['coding']);
assert.equal(before.project, null); assert.equal(before.writable, true); assert.match(before.revision, /^[a-f0-9]{64}$/);
const [saved, receipt] = await call(f, ORG, 'POST', { revision: before.revision, enabled: ['coding', 'montage'] });
assert.equal(saved, 200, JSON.stringify(receipt));
assert.deepEqual(receipt.enabled, ['coding', 'montage']);
assert.match(receipt.transaction, /^[0-9a-f-]{36}$/); assert.notEqual(receipt.revision, before.revision);
assert.deepEqual(f.onDisk(), ['coding', 'montage']);
const [, after] = await call(f, ORG);
assert.deepEqual(after.enabled, ['coding', 'montage']); assert.equal(after.revision, receipt.revision);
// The revision is the proof: the old one is refused and nothing is written.
const [stale, conflict] = await call(f, ORG, 'POST', { revision: before.revision, enabled: [] });
assert.deepEqual([stale, conflict.error], [409, 'revision-conflict']);
assert.deepEqual(f.onDisk(), ['coding', 'montage']);
});
test('the admission checks refuse before any write', async t => {
const f = await fixture(t);
const [, { revision }] = await call(f, ORG);
const good = { revision, enabled: [] };
for (const [label, body, headers, expected] of [
['unknown module', { revision, enabled: ['nope'] }, {}, [400, 'system-type-unknown']],
['duplicate', { revision, enabled: ['coding', 'coding'] }, {}, [400, 'invalid-request']],
['extra field', { ...good, actor: 'jason' }, {}, [400, 'invalid-request']],
['business field', { ...good, business: 'other' }, {}, [400, 'invalid-request']],
['no revision', { enabled: [] }, {}, [400, 'invalid-request']],
['bad revision', { revision: 'x', enabled: [] }, {}, [400, 'invalid-request']],
['not JSON', '{', {}, [400, 'invalid-request']],
['too large', JSON.stringify({ revision, enabled: [], pad: 'x'.repeat(5000) }), {}, [413, 'invalid-request']],
['form post', good, { 'content-type': 'text/plain' }, [400, 'invalid-request']],
// The Console's own origin check answers a foreign Origin first; the route's own check answers Sec-Fetch-Site.
['cross origin', good, { origin: 'https://other.example' }, [403, 'cross-origin request refused']],
['cross site', good, { 'sec-fetch-site': 'cross-site' }, [403, 'cross-origin']],
]) {
const writes = f.calls.filter(c => c[0] === 'write' && c[1] === 'plugins.set').length;
const [status, result] = await call(f, ORG, 'POST', body, headers);
assert.deepEqual([status, result.error], expected, label);
assert.equal(f.calls.filter(c => c[0] === 'write' && c[1] === 'plugins.set').length, writes, label);
assert.deepEqual(f.onDisk(), ['coding'], label);
}
const [method] = await call(f, ORG, 'PUT');
assert.equal(method, 405);
const [cross, crossBody] = await call(f, ORG, 'GET', undefined, { origin: 'https://other.example' });
assert.equal(cross, 403); assert.match(crossBody.error, /^cross-origin/);
const [badProject, badProjectBody] = await call(f, `${ORG}/projects/${encodeURIComponent('../x')}`);
assert.deepEqual([badProject, badProjectBody.error], [404, 'project-not-found']);
f.signOut();
const [signedOut, signedOutBody] = await call(f, ORG, 'POST', good);
assert.deepEqual([signedOut, signedOutBody.error], [401, 'sign-in-required']);
assert.deepEqual(f.onDisk(), ['coding']);
});
test('a sign-out while the read runs dispatches no write on either route', async t => {
let signOutOnRead = false;
const f = await fixture(t, { afterRead: () => { if (signOutOnRead) f.signOut(); } });
const [, { revision }] = await call(f, ORG);
signOutOnRead = true;
const writes = () => f.calls.filter(c => c[0] === 'write' && c[1] !== 'plugins').length;
for (const [path, body] of [[ORG, { revision, enabled: [] }], [`${ORG}/projects/${f.project}`, { systemTypes: ['coding'] }]]) {
const [status, result] = await call(f, path, 'POST', body);
assert.deepEqual([status, result.error], [401, 'sign-in-required'], path);
}
assert.equal(writes(), 0);
assert.deepEqual(f.onDisk(), ['coding']);
});
test('the enabled set and its revision come from one read, so a change in between is not undone', async t => {
let raced = false;
const f = await fixture(t, { beforeWrite: async verb => {
// Another owner action enables Marketing after the route's Console read and before its plugins read.
if (verb !== 'plugins' || raced) return;
raced = true;
const { revision } = await f.ownerRequest('plugins', {});
await f.ownerRequest('plugins.set', { revision, plugins: { 'system-type': ['coding', 'marketing'] } });
} });
const [, view] = await call(f, ORG);
assert.equal(raced, true);
assert.deepEqual(view.enabled, ['coding', 'marketing']);
const [saved, receipt] = await call(f, ORG, 'POST', { revision: view.revision, enabled: view.enabled.filter(x => x !== 'coding') });
assert.equal(saved, 200, JSON.stringify(receipt));
assert.deepEqual(f.onDisk(), ['marketing']);
});
test('without the owner human proof the enable switch is refused', async t => {
// A Console without --send has no write transport: the read works, the write is read-only.
const readOnly = await fixture(t, { send: false });
const [status, view] = await call(readOnly, ORG);
assert.equal(status, 200); assert.equal(view.writable, false); assert.equal(view.revision, undefined);
const [refused, body] = await call(readOnly, ORG, 'POST', { revision: 'a'.repeat(64), enabled: [] });
assert.deepEqual([refused, body.error], [403, 'read-only']);
assert.deepEqual(readOnly.onDisk(), ['coding']);
// A write transport bound as the tester or as an agent, not the owner's human cap, is refused by the
// broker, and the receipt-bearing path never runs.
for (const writer of ['tester', 'agent']) {
const f = await fixture(t, { writer });
const [fenced, fence] = await call(f, ORG, 'POST', { revision: 'b'.repeat(64), enabled: [] });
assert.deepEqual([fenced, fence.error], [403, 'human-required'], writer);
assert.deepEqual(f.onDisk(), ['coding'], writer);
}
});
test('a project selects only enabled modules and a disabled selection reads as inactive', async t => {
const f = await fixture(t);
const path = `${ORG}/projects/${f.project}`;
const [status, empty] = await call(f, path);
assert.equal(status, 200, JSON.stringify(empty));
assert.deepEqual(empty.project, { project: f.project, selected: [], active: [], inactive: [] });
assert.equal(empty.revision, undefined);
const [notEnabled, notEnabledBody] = await call(f, path, 'POST', { systemTypes: ['montage'] });
assert.deepEqual([notEnabled, notEnabledBody.error], [409, 'system-type-not-enabled']);
const [unknown, unknownBody] = await call(f, path, 'POST', { systemTypes: ['nope'] });
assert.deepEqual([unknown, unknownBody.error], [400, 'system-type-unknown']);
const [saved, selection] = await call(f, path, 'POST', { systemTypes: ['coding'] });
assert.equal(saved, 200, JSON.stringify(selection));
assert.deepEqual(selection, { project: f.project, selected: ['coding'], active: ['coding'], inactive: [] });
// The owner disables coding for the organization; the project keeps its selection, now inactive.
const [, { revision }] = await call(f, ORG);
assert.equal((await call(f, ORG, 'POST', { revision, enabled: [] }))[0], 200);
const [, after] = await call(f, path);
assert.deepEqual(after.enabled, []);
assert.deepEqual(after.project, { project: f.project, selected: ['coding'], active: [], inactive: ['coding'] });
const [missing, missingBody] = await call(f, `${ORG}/projects/no-such-project`);
assert.deepEqual([missing, missingBody.error], [404, 'project-not-found']);
const [missingWrite, missingWriteBody] = await call(f, `${ORG}/projects/no-such-project`, 'POST', { systemTypes: [] });
assert.deepEqual([missingWrite, missingWriteBody.error], [404, 'project-not-found']);
});
test('the tester listener and a Console without SSO never reach the routes or the files', async t => {
const { createDevOwner } = await import('../src/dev-owner.mjs');
let reached = 0;
const hit = async () => { reached++; return {}; };
const organizations = { list: async () => [{ id: 'demo', label: 'Demo', busHealthy: true }],
resolve: async () => ({ busHealthy: true, send: hit, requestRead: hit, systemTypesWrite: hit, reads: { business: async () => ({ business: { id: 'demo' } }) }, bus: {}, chat: { projects: async () => [], threads: async () => [] } }) };
const root = mkdtempSync(join(tmpdir(), 'stt-')), socket = join(root, 't.sock');
const tester = await startServer({ tester: true, testerSocket: socket, board: 'http://127.0.0.1:9', organizations, log: () => {} });
t.after(async () => { await close(tester); rmSync(root, { recursive: true, force: true }); });
const viaTester = (path, method = 'GET') => new Promise((resolve, reject) => {
const req = request({ socketPath: socket, path, method, headers: { host: 'mosaic-tester.invalid' } }, res => { res.resume(); res.on('end', () => resolve(res.statusCode)); });
req.on('error', reject); req.end();
});
for (const path of ['/system-types.js', '/system-types.css']) assert.equal(await viaTester(path), 403, path);
// The tester has no organization session, so the per-organization routes answer org-unavailable.
for (const [path, method] of [[ORG, 'GET'], [ORG, 'POST'], [`${ORG}/projects/p1`, 'GET']]) assert.ok([403, 404].includes(await viaTester(path, method)), path);
const plain = await startServer({ port: 0, organizations, log: () => {} }); t.after(() => close(plain));
const plainBase = `http://127.0.0.1:${plain.address().port}`;
assert.ok((await fetch(`${plainBase}${ORG}`)).status >= 400);
// The owner's dev session on a Console with the routes: the file is served.
const owner = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations, log: () => {} }); t.after(() => close(owner));
assert.equal((await fetch(`http://127.0.0.1:${owner.address().port}/system-types.js`)).status, 200);
assert.equal(reached, 0);
await assert.rejects(startServer({ port: 0, sso: createDevOwner(), systemTypesWrite: hit, log: () => {} }), /System types writes need/);
});
@@ -6,11 +6,13 @@ import { createOrgAdmin } from '../src/org-admin.mjs';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
import { fileURLToPath } from 'node:url';
import { systemTypesFake } from './helpers/system-types-fake.mjs';
const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
// Three organizations as sample 01 and 03 show them: two running buses and one stopped.
function workspaceOrgs({ failThreads = false, seen = null } = {}) {
// Three organizations as sample 01 and 03 show them: two running buses and one stopped. P2: each running
// organization's System types read comes from the installed modules; `send` adds the owner's writes.
function workspaceOrgs({ failThreads = false, seen = null, send = false, types = {} } = {}) {
const ORGS = [
{ id: 'ddigital', label: 'Distinctly Digital', theme: 'rosewood', busHealthy: true },
{ id: 'forge', label: 'Forge', theme: 'cobalt', busHealthy: true },
@@ -24,6 +26,8 @@ function workspaceOrgs({ failThreads = false, seen = null } = {}) {
const AGENTS = { ddigital: [{ role: 'chief', holder_run: 'r1', state: 'waiting' }, { role: 'copywriter', holder_run: 'r2', state: 'working' }], forge: [] };
const claim = (thread, holder_run) => ({ thread, holder_run, harness: 'claude', at: '2026-11-09T12:00:00.000Z' });
const CLAIMS = { ddigital: [claim('t3', 'r1'), claim('t1', 'r2'), claim('t2', 'r3')], forge: [] };
const TYPES = { ddigital: { enabled: ['coding', 'marketing'], projects: { 'spring-launch': ['coding'], 'brand-refresh': ['marketing'] } }, forge: { enabled: [], projects: { 'api-gateway': [] } }, ...types };
const fakes = {};
const FILES = { 'spring-launch': ['docs/north-star.md', 'docs/launch-plan.md', 'src/index.js'], 'brand-refresh': ['docs/brand-voice-guide.md'], 'api-gateway': [] };
const instances = id => ({ business: id, revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {},
instances: Object.fromEntries(Object.entries(INSTANCES[id]).map(([n, [definition, running]]) => [n, { definition, vars: {}, launched: true, launcher: false, protected: false, kept: false, running, alive: [] }])) });
@@ -35,8 +39,10 @@ function workspaceOrgs({ failThreads = false, seen = null } = {}) {
seen?.push(id);
const org = ORGS.find(o => o.id === id); if (!org) return null;
if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] } };
fakes[id] ??= systemTypesFake(TYPES[id]);
const st = await fakes[id];
return {
busHealthy: true,
busHealthy: true, requestRead: st.requestRead, systemTypesWrite: send ? st.systemTypesWrite : null, ...(send ? { send: async () => ({}) } : {}),
reads: { business: async () => business(id), settings: async () => ({ release: '0.0.42', notifier: { binding: 'discord' }, launch: { limits: { total: 4 } } }),
queue: async () => ({ rows: id === 'ddigital' ? [{ state: 'done' }, { state: 'in-progress' }, { state: 'queued' }] : [] }), busStatus: async () => ({}) },
bus: { instances: async () => instances(id), messages: async () => ({ messages: [], unread: 0 }), agents: async () => AGENTS[id], 'thread.claims': async () => CLAIMS[id], inbox: async () => [], tasks: async () => [] },
@@ -125,7 +131,8 @@ test('organization dashboard renders every section from the per-organization rea
assert.equal(head.avatar, 'DD'); assert.equal(head.title, 'Distinctly Digital');
assert.match(head.meta, /Kind not recorded/); assert.match(head.meta, /id ddigital/); assert.match(head.meta, /Tracker: Vikunja/); assert.match(head.meta, /bus running, read \d/);
assert.match(head.colour, /^#[0-9a-f]{6}$/i);
assert.match(await b.evaluate('document.querySelector(".ws-typechips").innerText'), /System types\s+No system types yet/i);
// System types: the enabled modules by their own labels (P2).
assert.match(await b.evaluate('document.querySelector(".ws-typechips").innerText'), /System types\s+Coding\s*Marketing\s+Each project picks from these/i);
// Docs come from the project files reads: only documents, named by project.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".ws-doc")].map(a => a.innerText.replace(/\\s+/g, " ").trim())'), ['North star Spring launch', 'Launch plan Spring launch', 'Brand voice guide Brand refresh']);
// KPIs and metrics: one honest empty tile each, with no invented number.
@@ -150,7 +157,8 @@ test('organization dashboard renders every section from the per-organization rea
for (const s of settings) { assert.ok(s.icon, s.name); assert.ok(s.value, s.name); assert.ok(TAGS.includes(s.tag), `${s.name}: ${s.tag}`); }
const by = Object.fromEntries(settings.map(s => [s.name, s]));
assert.deepEqual([by['Members and invites'].tag, by['Members and invites'].href], ['not built', null]);
assert.deepEqual([by['System types'].tag, by['System types'].href], ['not built', null]);
assert.deepEqual([by['System types'].tag, by['System types'].value], ['org file', '2 of 5 installed enabled']);
assert.equal(await b.evaluate('document.querySelector(\'[data-org-setting="System types"] button.go\').textContent'), 'Open');
assert.deepEqual([by['Archive or delete'].tag, by['Archive or delete'].danger, by['Archive or delete'].href], ['danger', true, '#/settings/organizations']);
assert.equal(by.Identity.value, 'Distinctly Digital · ddigital · kind not recorded');
assert.equal(by['Task tracker'].value, 'Vikunja');
@@ -170,7 +178,8 @@ test('organization dashboard empty states say so in one short line', async t =>
await waitFor(b)('document.querySelector("[data-org-dashboard] [data-org-settings]")');
const text = await b.evaluate('document.querySelector("[data-org-dashboard]").innerText');
// Section labels are uppercased by CSS, so innerText carries the uppercase form.
for (const line of [/System types\s+No system types yet\. They arrive with the system types update\./i, /Docs\s+No docs yet/i, /No KPIs defined in the organization file/, /No metrics recorded yet/, /Active agent sessions\s+none running/i, /No active agent sessions/, /Queue\s+no rows/i, /Tracker: not set/])
// The empty organization has no System types read, so it says unavailable, never that none exist.
for (const line of [/System types\s+System types unavailable/i, /Docs\s+No docs yet/i, /No KPIs defined in the organization file/, /No metrics recorded yet/, /Active agent sessions\s+none running/i, /No active agent sessions/, /Queue\s+no rows/i, /Tracker: not set/])
assert.match(text, line);
assert.equal(await b.evaluate('document.querySelectorAll("[data-org-settings] > li").length'), SETTINGS.length);
});
@@ -8,6 +8,7 @@ test('integrated project settings and conversation mounts use authorized context
[`GET /api/chat/rooms/${ids.project}/messages?limit=100`]: {status:503,body:{error:'fixture-unavailable'}},
'GET /api/project-files?project=fixture-repository': {status:503,body:{error:'fixture-unavailable'}},
'GET /api/project-file?project=fixture-repository&path=spec%2Fnorth-star.md': {status:503,body:{error:'fixture-unavailable'}},
[`GET /api/system-types/projects/${ids.project}`]: {status:503,body:{error:'fixture-unavailable'}},
'/api/project-remotes/fixture-repository': { project: ids.repository, remote: null, revision: 'a'.repeat(64), firstSaveAllowed: true },
};
const { f, b, wait } = await w15Fixture(t, { scenario });