Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f14a6180e | ||
|
|
b46b301dab |
No files matched your search
@@ -1,671 +0,0 @@
|
||||
# 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 13 that touch role or rights policy land only
|
||||
with Jason's approval.
|
||||
|
||||
Round 2 follows ruling 13. Merge and policy decisions stay with the arbiter
|
||||
agent. The rails split into system rails and role rails (section 3), and a
|
||||
per-organization autonomy setting decides where each class of decision goes
|
||||
(section 9).
|
||||
|
||||
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 `f98f278fb`. Its code is the
|
||||
same as 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.
|
||||
13. Trust, verbatim, answering whether merge and policy decisions should go to a person instead of the arbiter agent: "I need to be able to trust the agents to do their jobs. The agent role should establish the rails they operate upon. Tooling and instructions should be clear. We are aiming for a move from "Human in the loop" to "Human near the loop" to "Human out of the loop" operations. We are currently HEAVILY in Human in the loop." They stay with the arbiter.
|
||||
|
||||
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
|
||||
|
||||
Rails come in two layers. System rails hold for every role at every autonomy
|
||||
level, and only a reviewed commit changes one. Role rails are what each role's
|
||||
definition says its agent may do. Ruling 13 asks for both: agents trusted to
|
||||
do their jobs, inside rails their role sets.
|
||||
|
||||
### System rails
|
||||
|
||||
Nothing changes these while the system runs: no role, template, plugin,
|
||||
approval or autonomy setting. Each row says where the code enforces the rail
|
||||
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, binding, setting and reverse entries use the same tables (A4, AU). |
|
||||
| R3 | Fail closed: an unknown action, tool, right, plugin kind, role field or autonomy value 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. AU refuses an unknown class or level. |
|
||||
| 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`). | The brief's system-rail list did not name it, and "any capacity" must not dissolve it. A hard wait leaves the list only when a PRD version moves it into `ROUTED` (REQ-WAIT-3, `vocabulary.mjs:67-69`), never through the autonomy setting. P1 keeps it for every composition. |
|
||||
| R5 | No agent widens its own authority. No agent resolves a decision it raised, a decision whose subject is itself or a definition it runs, or anything that changes the autonomy setting or a rail. | An arbiter's own raise goes to the human (`broker.mjs:7075`). PRD approval refuses an arbiter that is the PM or an author (`broker.mjs:1060`). | Rewritten in round 2. Round 1 also sent merge and policy decisions to a person; ruling 13 reverses that, and REQ-DEC-2 stays as ratified. Nothing checks a decision's subject against its resolver today. AU adds that check, which sends such a decision to a person. |
|
||||
| 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`). | 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 a flagged line the owner approves. This holds at every autonomy level. | 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`). | 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. An organization's setting cannot move it. | The loader reads only its own tree and takes no path from config (`packages/plugins/src/loader.mjs:1-5`). | 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. |
|
||||
| R12 | Rails change only by reviewed commit, and an organization's autonomy setting only by its owner. | The vocabulary and `roles/` change by reviewed commit (AGENTS.md invariant 7). | New in round 2. The setting does not exist yet; AU adds it as an owner-only Console verb. |
|
||||
|
||||
Round 1 changed R5 and R6 from the brief. Round 2 rewrites R5 again and adds
|
||||
R12.
|
||||
|
||||
### Role rails
|
||||
|
||||
A role's definition is its rails. Each part says what that agent may do, and
|
||||
the broker enforces it:
|
||||
|
||||
| Part | What it says | Enforced today | Gap |
|
||||
|---|---|---|---|
|
||||
| `tools` | The tools the agent has | Typed bus tools follow the held actions (`tools.mjs:97`); built-ins follow the policy (`packages/harness/src/bundle.mjs:368`) | None |
|
||||
| `authority.withinRole` | Actions the agent takes alone | The broker's classification (`broker.mjs:7048`) | None |
|
||||
| `authority.crossRole` | Actions it raises to its domain's arbiter | `broker.mjs:7072-7075` | The raiser picks the domain (`broker.mjs:7067`), so it picks its arbiter. RR fixes the domain for merge and policy. |
|
||||
| `decision.resolve.<domain>` in `withinRole` | Which decisions it may resolve for other agents | Only for PRD approval (`broker.mjs:1059`) | An ordinary resolve checks only that the caller's role is the route (`broker.mjs:7212-7215`). The organization's `arbiters` map names the arbiter, not its role (`org-template.json:5`), and the default arbiter, the chief, holds no resolve right (`roles/chief-of-staff.json:8-9`). RR makes an arbiter hold the right for its domain. |
|
||||
| `credentials` | Each service and scope | Bound by a person only (`broker.mjs:6144`) | None |
|
||||
| `messaging` | Whom it may message | `#checkMessaging` (`broker.mjs:7082`) | None |
|
||||
|
||||
**The validator carries them.** P1's `checkComposition` checks every part
|
||||
against the registry, and treats every `decision.resolve.*` action as a
|
||||
flagged line, because it lets the holder decide for other agents.
|
||||
|
||||
**The instructions state them plainly.** The contract keeps the duties and
|
||||
says what to do on a refusal. The list of what the agent may do is generated
|
||||
from the resolved definition into its prompt (section 10), so the wording
|
||||
cannot drift from what the broker enforces. Every shipped contract has an
|
||||
`## Authority` section today; RR makes the role check refuse a version 2
|
||||
contract without one (`role.mjs:146-157`).
|
||||
|
||||
## 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. returns every `decision.resolve.*` action as a flagged line (role rails);
|
||||
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, resolve
|
||||
its own request or a decision about itself, 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`). It routes by the organization's autonomy setting
|
||||
for authority changes (section 9). At the default, in the loop, it goes to a
|
||||
person, so the arbiter path at `broker.mjs:7074` does not apply. A hire needs no
|
||||
approval count and has no cap (ruling 3). Spend stays bounded by the
|
||||
organization's limits.
|
||||
|
||||
**Approve, in the loop.** A human whose membership in that organization holds
|
||||
`org.hire` approves in the Console, against the current digest. Two limits,
|
||||
at every autonomy level:
|
||||
|
||||
- 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.
|
||||
|
||||
**Near and out of the loop.** Only a plain decision moves: no flagged line, no
|
||||
credential line, organization or project level. An instance whose role holds
|
||||
`decision.resolve.authority` resolves it. It is never the chief that raised it
|
||||
and never the agent the decision names (R5). Near the loop, the owner is told
|
||||
and can reverse it (section 9).
|
||||
|
||||
**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. Autonomy, usability and security
|
||||
|
||||
Read this first. It says who decides what, and what changes as trust grows.
|
||||
|
||||
Ruling 13 sets the direction: from a person in the loop, to near the loop, to
|
||||
out of the loop. Today most decisions sit in the loop.
|
||||
|
||||
**The three levels.**
|
||||
|
||||
- **In the loop.** A person approves before anything happens.
|
||||
- **Near the loop.** An agent whose role holds the resolve right decides, and
|
||||
the decision takes effect. A person is told in the Console and can reverse
|
||||
it within a window the owner sets. I propose 24 hours as the default.
|
||||
- **Out of the loop.** An agent whose role holds the resolve right decides.
|
||||
The trail records it, and nobody is told.
|
||||
|
||||
For a class that routes to an arbiter today (cross-role work, merge, policy),
|
||||
the arbiter resolves at every level, as PRD 1.5 REQ-DEC-2 and acceptance 314
|
||||
step 5 require. The level only decides what happens after the arbiter
|
||||
resolves. In the loop, the resolution waits for a person to confirm it before
|
||||
it takes effect. A person who declines sends it back to the arbiter with a
|
||||
reason. Near and out of the loop, it takes effect at once. No level makes a
|
||||
person the resolver of these classes.
|
||||
|
||||
**The setting.** An organization's file gets an `autonomy` key with one level
|
||||
per class below. A missing key means today's routing. Only the owner changes
|
||||
it, in the Console: the verb maps to `null` in `VERB_RIGHTS`, the explicit
|
||||
owner-only mark (`org-rights.mjs:28-29`), and it is not on the tester's verb
|
||||
list (`broker.mjs:192`). Each change is a trail event. No agent changes it at
|
||||
any level (R12). The owner can pull a class back toward the loop at any time
|
||||
with no preconditions.
|
||||
|
||||
**Classes and levels.** "Default" marks where each class sits today.
|
||||
|
||||
| Class | In the loop | Near the loop | Out of the loop |
|
||||
|---|---|---|---|
|
||||
| Cross-role work, such as a task scope change | The arbiter resolves; a person confirms before it takes effect | The arbiter resolves; a person is told | The arbiter resolves (default, `broker.mjs:7072-7075`) |
|
||||
| Merge, `git.merge.protected` | The technical arbiter resolves; a person confirms before it lands | The technical arbiter resolves; a person is told | The technical arbiter resolves (default). Acceptance 314 step 5 has the CTO do it (`docs/acceptance/314.md:116-120`) |
|
||||
| Policy, `policy.change` | The arbiter resolves; a person confirms before it takes effect | The arbiter resolves; a person is told | The arbiter resolves (default). No shipped role holds this action |
|
||||
| Plain hire, fire or role change | A person with `org.hire` approves (default, ruling 1) | An agent holding `decision.resolve.authority` resolves; the owner is told | The same agent resolves |
|
||||
| Flagged grants and credential bindings | The owner approves (default) | Does not move (R8) | Does not move |
|
||||
| Root-level agents | The instance owner (default) | Not an organization setting | Not an organization setting |
|
||||
| Plugin install | The instance owner, at the host (default) | Not an organization setting (R10) | Not an organization setting |
|
||||
| Hard waits: push, deploy, spend, mint, delete, sudo | A person, every time (R4) | Not a setting | Not a setting |
|
||||
| The autonomy setting | The owner (R12) | Never | Never |
|
||||
|
||||
PRD approval already has its own switch: a request in `autonomous` mode sends
|
||||
it to the technical arbiter, and one in `guided` mode to a person
|
||||
(`packages/bus/src/request-lifecycle.mjs:308`, `broker.mjs:1057-1060`). A
|
||||
person sets the mode (`broker.mjs:5918-5919`). This design leaves it as is.
|
||||
|
||||
**What reverse can undo.** Near the loop, reverse is a one-step act by the
|
||||
owner, recorded against the decision. After the window it is an ordinary new
|
||||
decision.
|
||||
|
||||
| Class | Reverse does | It cannot undo |
|
||||
|---|---|---|
|
||||
| Cross-role work | Restores the task's prior scope or state | Messages sent, records written (write-once, R2), work done in the window |
|
||||
| Merge | Lands a revert commit through the normal path | The merge in history (no force push, AGENTS.md invariant 8), or anything that ran or shipped the merged code in the window |
|
||||
| Policy | Restores the prior policy | Actions taken under the new policy in the window |
|
||||
| Hire | Fires the agent, which revokes its credentials (`instance-settings.mjs:396-414`) | What the agent did or spent in the window |
|
||||
| Fire | Rehires the same definition `rev` | The run and session state the fire ended, and the credential bindings the fire revoked. Each needs a new binding the owner approves |
|
||||
| Role change | Returns the definition to the prior `rev`, and relaunches the agent if it runs the new one. A narrowing back takes effect at the next broker call (section 5) | What the agent did under the new `rev` |
|
||||
| Credential binding | Not offered: it does not move | Each use of the token at the service. That is why it stays in the loop |
|
||||
|
||||
**Before a class moves a level.** The organization needs:
|
||||
|
||||
- to move any class to near the loop: a resolver instance whose role holds
|
||||
that class's resolve right and is not the raiser (RR), the class's reverse
|
||||
built and tested (AU), and Console notices on for the owner;
|
||||
- to move any class to out of the loop: time at near the loop, and the
|
||||
owner's review of its reversals in the trail;
|
||||
- for merge, in addition: a reviewer role whose approve verdict a landing
|
||||
needs, and the land gate's tests. Acceptance 314 has both, which is why
|
||||
merge stays out of the loop by default;
|
||||
- for policy, in addition: a reviewer role that sees the policy diff first;
|
||||
- for plain hires, in addition: organization spend limits that cover a new
|
||||
agent, and a resolver other than the chief. Out of the loop also needs
|
||||
per-agent limits in every definition the resolver can approve.
|
||||
|
||||
Flagged grants, credential bindings, root-level agents and plugin installs do
|
||||
not move in this design. Moving one needs a new ruling.
|
||||
|
||||
**A person changes directly.** Organizations and projects (owner). Drafts of
|
||||
role copies and invented roles, which do nothing until a decision uses them
|
||||
(`org.hire`). An agent's model, limits and route (`org.hire`). An agent's
|
||||
personality and traits after hire (ruling 12). Enabling a plugin in an
|
||||
organization (owner). Installing a plugin (instance owner, at the host).
|
||||
|
||||
**Only a reviewed code change.** The rails in section 3, the core list of
|
||||
tools and actions, the shipped role templates, the rights table including
|
||||
`org.hire`, plugin kinds, and system config, which only bootstrap writes.
|
||||
|
||||
**What never moves, at any level:**
|
||||
|
||||
- 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 resolve its own request or a decision about itself, and it
|
||||
cannot change the autonomy setting or a rail.
|
||||
- An agent cannot grant more than the chief holds, or bind a credential,
|
||||
without the owner.
|
||||
- Only the chief raises hires, and a hired agent cannot hire.
|
||||
- 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. Tooling and instructions
|
||||
|
||||
An agent trusted to work without a person has to know its own limits without
|
||||
asking one. Three things today fall short.
|
||||
|
||||
**Its rails, readable at runtime.** The prompt names the agent's built-in and
|
||||
bus tools (`packages/harness/src/bundle.mjs:283-284`) and the held actions
|
||||
that have no bus tool (`bundle.mjs:308`). It does not say which actions the
|
||||
agent takes alone, which go to which arbiter, which go to a person, or what it
|
||||
may resolve. Six contracts tell the agent "the launch record you received
|
||||
shows the resolved list" (`roles/coder.md:41`, `roles/cto.md:46`,
|
||||
`roles/merge-gate.md:54`, `roles/pm.md:109`, `roles/reviewer.md:38`,
|
||||
`roles/security-reviewer.md:27`). No session receives one. Launch records are
|
||||
host-side, for principal checks (`packages/bus/src/runtime.mjs:885-901`,
|
||||
`broker.mjs:2047`).
|
||||
|
||||
Fix, in TI: the prompt gets a generated "## Your rails" section from the
|
||||
resolved definition. It lists each held action with where it goes (alone,
|
||||
which arbiter instance, or a person), each resolve right with its class's
|
||||
autonomy level, the network, each credential service and scope by name, and
|
||||
the messaging targets. A read-only `my_rails` bus tool returns the same from
|
||||
the broker, since a narrowing takes effect at the next call (section 5). The
|
||||
six contracts drop the launch-record sentence and point at that section.
|
||||
|
||||
**Refusals that say what to do next.** The broker's `fail(code)` throws a
|
||||
bare code (`broker.mjs:251-253`), and 653 lines of `broker.mjs` call it. A
|
||||
reason travels only where a call builds one (`broker.mjs:243-249`, for
|
||||
example `941`, `961` and `6468`). The prompt says only that "a refusal names
|
||||
its reason" (`bundle.mjs:284`).
|
||||
|
||||
Fix, in TI: one table maps each refusal code to the rail or role rail it
|
||||
enforces and the next step, for example "raise a decision for the delivery
|
||||
arbiter" or "a person decides this; raise it with blocking false". The
|
||||
envelope carries both. A test fails when a `fail` code has no entry.
|
||||
|
||||
**The tools it holds.** These are covered. The prompt names each tool
|
||||
(`bundle.mjs:283-284`) and the bundle writes each tool's description and
|
||||
schema to `tools.json` for the harness (`bundle.mjs:372`). What was missing
|
||||
is the routing behind each tool, which the rails section adds.
|
||||
|
||||
## 11. 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 resolves a bad merge | Its role must hold `decision.resolve.technical` (RR). It never resolves its own raise (R5). The review verdict and the land gate come first (acceptance 314). The owner can pull merge back toward the loop. | A wrong merge is reverted, not undone. |
|
||||
| An agent widens its own authority through a decision | No agent resolves its own raise or a decision about itself (R5). Flagged grants and credentials stay with the owner at every level (R8). Only the owner changes the setting (R12). | One agent raises a plain change for another. Plain means within the chief's own authority, and near the loop tells the owner. |
|
||||
| A prompt-injected resolver near the loop | The owner is told and can reverse within the window. | What reverse cannot undo (section 9). |
|
||||
| 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. |
|
||||
|
||||
## 12. 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, resolved where the organization's owner-set
|
||||
> autonomy level sends it, by default a human with `org.hire`. The
|
||||
> decision lists every grant above the chief's own as a flagged line,
|
||||
> which only the owner approves. 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".
|
||||
|
||||
## 13. Slices
|
||||
|
||||
Phase A after A3, in order: R1, P1, T1, A4, RR, TI, AU, PL1. A6 and A7 stay
|
||||
as agent-levels section 9 has them.
|
||||
|
||||
Slices that change `roles/`: A4 (the chief), RR (the chief, and any role an
|
||||
organization names as delivery arbiter) and TI (six contracts). Slices that
|
||||
change rights policy: R1 and AU. P1 changes no file in `roles/` but defines
|
||||
authority policy. All of them need Jason's approval.
|
||||
|
||||
**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; a `decision.resolve.*` action comes back as a flagged line;
|
||||
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, and removal of the direct
|
||||
path and `#hireable`. Every hire decision sits in the loop until AU. 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,
|
||||
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`
|
||||
decision still routes to the technical arbiter and the CTO resolves it, as
|
||||
acceptance 314 step 5 expects; a hire a person starts in the Console leaves a decision record
|
||||
with its flagged lines.
|
||||
|
||||
**RR. Role rails and resolve rights.** An arbiter must hold
|
||||
`decision.resolve.<domain>` for its domain, checked when the organization
|
||||
file is written and again at resolve, which extends `broker.mjs:1059` to
|
||||
every decision. The vocabulary adds `decision.resolve.delivery`. A fixed
|
||||
domain per action for `git.merge.protected` (technical) and `policy.change`
|
||||
(delivery), so a raiser cannot pick its arbiter. The chief gets both resolve
|
||||
rights, since the template names it arbiter for both
|
||||
(`org-template.json:5`). The role check refuses a version 2 contract with no
|
||||
`## Authority` section. Changes `roles/chief-of-staff.json` and the
|
||||
vocabulary, so Jason's approval. Tests: naming an arbiter without the right
|
||||
refuses when written; an arbiter whose role lost the right refuses
|
||||
`not-resolver`; a merge raised with domain `delivery` still routes to the
|
||||
technical arbiter; acceptance 314 step 5, the CTO resolving the merge, still
|
||||
passes; a contract with no `## Authority` refuses.
|
||||
|
||||
**TI. Tooling and instructions.** The generated "## Your rails" prompt
|
||||
section, the read-only `my_rails` tool, the refusal table and envelope
|
||||
fields, and the six contract edits (section 10). Changes `roles/*.md`, so
|
||||
Jason's approval. Tests: coder's prompt lists each held action with its
|
||||
route; `my_rails` matches the broker after a narrowing; every `fail` code in
|
||||
`broker.mjs` has a table entry; a refused cross-role call's envelope names
|
||||
its rail and next step.
|
||||
|
||||
**AU. Autonomy setting and routing.** The `autonomy` key, the owner-only
|
||||
Console verb, routing per class, the subject check that sends a decision
|
||||
about its resolver to a person (R5), `decision.resolve.authority`, the
|
||||
Console notice, reverse per class with its window, and the trail events.
|
||||
Needs A4 and RR. Changes rights policy (a `VERB_RIGHTS` entry) and the
|
||||
vocabulary, but no role file, so Jason's approval. Acceptance tests:
|
||||
|
||||
- with no `autonomy` key, routing is exactly today's: a merge goes to the
|
||||
technical arbiter, a hire to a person;
|
||||
- a member, an agent and the dev tester each refuse to change the setting;
|
||||
an unknown class or level refuses;
|
||||
- with merge in the loop, the technical arbiter still resolves the merge
|
||||
decision, a person's resolve refuses `not-resolver`, and the merge does not
|
||||
land until a person confirms; a decline returns it to the arbiter;
|
||||
- with merge near the loop, the arbiter's resolve takes effect, the owner
|
||||
gets a notice, and reverse within the window lands a revert commit
|
||||
recorded against the decision;
|
||||
- with hires near the loop, an instance holding `decision.resolve.authority`
|
||||
resolves a plain hire; a hire with a flagged or credential line still goes
|
||||
to the owner; the chief cannot resolve its own raise; no agent resolves a
|
||||
change that names itself;
|
||||
- reverse after the window refuses and names the ordinary decision path;
|
||||
- each setting change is a trail event with the owner as actor;
|
||||
- no setting value moves a hard wait, a flagged grant or a credential
|
||||
binding away from a person.
|
||||
|
||||
**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.
|
||||
@@ -1,212 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,2 +0,0 @@
|
||||
// Old path of packages/organization/src/composition.mjs (rename design r2, stage B).
|
||||
export * from "../../organization/src/composition.mjs";
|
||||
@@ -11,7 +11,7 @@ import { consoleBusRestart } from './org-console.mjs';
|
||||
import { refuseInsideAgent } from './transport.mjs';
|
||||
export const ORG_USAGE = 'mosaic org system-types installed | system-types enabled ID | lock show|clear ID | reload ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | config validate ID | config resolve ID INSTANCE [--project ID]';
|
||||
function parse(args) { const pos = [], values = {}, flags = new Set(); for (let i = 0; i < args.length; i++) { const a = args[i]; if (['--json', '--local-only', '--accept-local-loss-risk'].includes(a)) { if (flags.has(a)) throw new CliError(`duplicate ${a}`, 4); flags.add(a); } else if (['--fixture', '--output', '--from', '--if-revision', '--remote', '--confirm'].includes(a)) { if (values[a] !== undefined || !args[i+1] || args[i+1].startsWith('--')) throw new CliError(`${a} needs one value`, 4); values[a] = args[++i]; } else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4); else pos.push(a); } return { pos, values, flags }; }
|
||||
async function document(file) { try { return JSON.parse(await readFile(file, 'utf8')); } catch (e) { throw new CliError(`cannot read document ${file}: ${e instanceof SyntaxError ? 'not valid JSON' : e.message}`, 2); } }
|
||||
async function document(file) { try { return JSON.parse(await readFile(file, 'utf8')); } catch (e) { throw new CliError(`cannot read document ${file}: ${e.message}`, 2); } }
|
||||
export async function orgCommand(args, io, deps = {}) {
|
||||
try {
|
||||
const p = parse(args); const words = p.pos; let result; let accepted = [];
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { loadSystem } from '../src/config.mjs';
|
||||
import { tmp } from './helpers.mjs';
|
||||
|
||||
const repo = resolve(import.meta.dirname, '../../..');
|
||||
const configScript = join(repo, 'scripts/mosaic-config.mjs');
|
||||
const cli = join(repo, 'packages/cli/src/cli.mjs');
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
function fixture(t) {
|
||||
const root = tmp(t, 'jr-'), home = join(root, 'home');
|
||||
const file = join(home, '.config/mosaic-redacttest/config.json');
|
||||
const env = { PATH: process.env.PATH, HOME: home, TMPDIR: process.env.TMPDIR, LANG: 'C.UTF-8', MOSAIC_CONFIG: file };
|
||||
const run = (script, ...args) => spawnSync(process.execPath, [script, ...args], { env, cwd: repo, encoding: 'utf8' });
|
||||
const boot = () => {
|
||||
const result = run(configScript, 'bootstrap', '--instance', 'redacttest');
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
};
|
||||
return { root, file, env, run, boot };
|
||||
}
|
||||
function redacted(result, file, status) {
|
||||
assert.equal(result.status, status, result.stderr);
|
||||
assert.equal(result.stdout, '');
|
||||
assert.ok(result.stderr.includes(file), result.stderr);
|
||||
assert.match(result.stderr, /not valid JSON/);
|
||||
assert.doesNotMatch(result.stderr, /sk-test|Unexpected token/);
|
||||
}
|
||||
|
||||
test('system config parse refusal and CLI relay omit parser bytes', t => {
|
||||
const f = fixture(t);
|
||||
mkdirSync(dirname(f.file), { recursive: true }); writeFileSync(f.file, secret);
|
||||
redacted(f.run(configScript, 'validate', '--instance', 'redacttest'), f.file, 2);
|
||||
assert.throws(() => loadSystem({ env: f.env, instance: 'redacttest' }), error => {
|
||||
assert.equal(error.exitCode, 3);
|
||||
assert.ok(error.message.includes(f.file));
|
||||
assert.match(error.message, /not valid JSON/);
|
||||
assert.doesNotMatch(error.message, /sk-test|Unexpected token/);
|
||||
return true;
|
||||
});
|
||||
redacted(f.run(cli, 'org', 'config', 'validate', 'alpha'), f.file, 3);
|
||||
});
|
||||
|
||||
test('named instance marker parse refusal omits parser bytes', t => {
|
||||
const f = fixture(t); f.boot();
|
||||
const { dataRoot } = JSON.parse(readFileSync(f.file, 'utf8'));
|
||||
const marker = join(dataRoot, 'instance.json'); writeFileSync(marker, secret);
|
||||
redacted(f.run(configScript, 'validate', '--instance', 'redacttest'), marker, 3);
|
||||
});
|
||||
|
||||
for (const operation of ['validate', 'create', 'update']) test(`org ${operation} document refusal omits parser bytes`, t => {
|
||||
const f = fixture(t); f.boot();
|
||||
const file = join(f.root, 'alpha.json'); writeFileSync(file, secret);
|
||||
const args = operation === 'validate' ? ['validate', file]
|
||||
: [operation, 'alpha', '--from', file, ...(operation === 'update' ? ['--if-revision', '1'] : [])];
|
||||
redacted(f.run(cli, 'org', ...args), file, 2);
|
||||
});
|
||||
@@ -98,7 +98,7 @@ export function sessionModel(resolved, models = null, which = "primary", { proxy
|
||||
});
|
||||
if (models === null) return { harness, provider: null, model, thinking };
|
||||
const entry = Object.hasOwn(models, model) ? models[model] : null;
|
||||
if (!entry) throw new BundleError(`instance ${resolved.instance}: unknown model table selection for ${ROUTE_VARS[which][1]} var`);
|
||||
if (!entry) throw new BundleError(`instance ${resolved.instance}: unknown model table selection ${model}`);
|
||||
const transport = entry.provider === "proxy" || proxy ? checkedTransport(entry, null, resolver) : null;
|
||||
return { harness, provider: entry.provider, model: entry.model, modelKey: model, thinking,
|
||||
...(transport ? { proxyRoute: transport.routeId, proxyTransport: transport } : {}) };
|
||||
|
||||
@@ -125,20 +125,7 @@ test("sessionModel: the route is the instance's own vars, and a missing one refu
|
||||
const fb = resolvedFor(t, "coder", (d) => (d.roles.coder.vars = { harness: "claude-code", model: "claude-opus-5-5", thinking: "max", "fallback.harness": "pi", "fallback.model": "glm-5.3-flash", "fallback.thinking": "minimal" }));
|
||||
assert.deepEqual(sessionModel(fb, null, "fallback"), { harness: "pi", provider: null, model: "glm-5.3-flash", thinking: "minimal" });
|
||||
assert.throws(() => sessionModel(resolvedFor(t, "pm"), null, "fallback"), (e) => e.code === "no-route" && e.missing === "fallback.harness");
|
||||
assert.throws(() => sessionModel(resolvedFor(t, "coder"), models), /unknown model table selection for model var/);
|
||||
});
|
||||
|
||||
for (const [route, name] of [["primary", "model"], ["fallback", "fallback.model"]]) test(`unknown ${route} model selection names the variable without its value`, (t) => {
|
||||
const secret = "sk-test-NOT-A-SECRET-1234";
|
||||
const resolved = resolvedFor(t, "coder");
|
||||
const vars = { ...resolved.vars, [name]: secret, "fallback.harness": "pi", "fallback.thinking": "high" };
|
||||
assert.throws(() => sessionModel({ ...resolved, vars }, {}, route), error => {
|
||||
assert.ok(error instanceof BundleError);
|
||||
assert.equal(error.code, "bundle-refused");
|
||||
assert.equal(error.message, `instance coder: unknown model table selection for ${name} var`);
|
||||
assert.ok(!error.message.includes(secret));
|
||||
return true;
|
||||
});
|
||||
assert.throws(() => sessionModel(resolvedFor(t, "coder"), models), /unknown model table selection glm-5.3-flash/);
|
||||
});
|
||||
|
||||
test("a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory", (t) => {
|
||||
|
||||
@@ -87,7 +87,7 @@ export function loadRootAgents(configDir, { rolesDir } = {}) {
|
||||
if (error?.name !== "BusinessError") throw error;
|
||||
if (error.exitCode === 4 && /not found/.test(error.message)) return deepFreeze({ state: "absent", agents: {}, assistant: null });
|
||||
// The read's messages carry the host path; the Console shows this one, so it names the file only.
|
||||
// Keep parse refusals file-only here too, without the host path.
|
||||
// A parse refusal also quotes the parser, which can quote the file's bytes: say only that it is not JSON.
|
||||
if (/ is not valid JSON /.test(error.message)) refuse(`root agent file is not valid JSON (${ROOT_FILE})`, error.exitCode);
|
||||
refuse(error.message.replaceAll(file, ROOT_FILE), error.exitCode);
|
||||
}
|
||||
|
||||
@@ -1,518 +0,0 @@
|
||||
// Roles round 2, slice P1 (#1873): the primitives registry and the composition validator.
|
||||
// Design: docs/plans/2026-11-10_roles-and-rails-design.md sections 3, 4 and 7, and the P1
|
||||
// paragraph of section 13.
|
||||
//
|
||||
// A role is a composition of the primitives in section 4 and nothing else: tools, network,
|
||||
// authority, credentials, messaging, skills, instructions and personality. checkComposition
|
||||
// checks one against a registry (the core set from vocabulary.mjs plus a plugin-declared set) in
|
||||
// the seven steps section 4 lists, in order, and returns the lines a human must see flagged.
|
||||
//
|
||||
// Nothing calls checkComposition yet. HIRE_ALLOWED and unhireableAction (vocabulary.mjs) still
|
||||
// bound a hire, and role.mjs and the broker still use them. Slice A4 switches hire over to this
|
||||
// validator and removes them. In P1 the plugin set is whatever the caller passes, empty by
|
||||
// default; nothing here reads a plugin file (PL1 builds install and the manifest).
|
||||
//
|
||||
// A refusal names the field and the position at fault. It quotes a value only when that value is
|
||||
// a registered name (a core or plugin action, tool, network, service or scope), so no role text,
|
||||
// and no secret placed where a name belongs, reaches an error. Free text (instructions,
|
||||
// personality) is never quoted.
|
||||
|
||||
import { refuse } from "./errors.mjs";
|
||||
import {
|
||||
ACTIONS, ROUTED, GATED_ONLY, TOOLS, NETWORKS, SERVICES, HOST_SERVICE, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE,
|
||||
ID_PATTERN,
|
||||
} from "./vocabulary.mjs";
|
||||
import { NAME as PLUGIN_NAME } from "../../plugins/src/kinds.mjs";
|
||||
import { deepFreeze } from "./util.mjs";
|
||||
|
||||
// The primitives (section 4). Every other key refuses (step 1).
|
||||
export const COMPOSITION_KEYS = Object.freeze([
|
||||
"tools", "network", "authority", "credentials", "messaging", "skills", "instructions", "personality",
|
||||
]);
|
||||
const REQUIRED = Object.freeze(["tools", "network", "authority", "credentials", "instructions"]);
|
||||
const AUTHORITY_KEYS = Object.freeze(["withinRole", "crossRole"]);
|
||||
const MESSAGING_KEYS = Object.freeze(["sendTo", "receiveFrom"]);
|
||||
const PERSONALITY_KEYS = Object.freeze(["summary", "traits", "voice"]);
|
||||
const CREDENTIAL_KEYS = Object.freeze(["service", "scopes"]);
|
||||
const PLUGIN_KEYS = Object.freeze(["name", "tools", "actions", "services"]);
|
||||
const PLUGIN_SERVICE_KEYS = Object.freeze(["name", "scopes"]);
|
||||
|
||||
// Step 6's length bounds, in characters or entries. The design gives none, so P1 sets them. The
|
||||
// largest shipped contract is about 8 KB; instructions get twice that. T1 stores personality
|
||||
// with these same bounds.
|
||||
export const COMPOSITION_BOUNDS = Object.freeze({
|
||||
instructions: 16384,
|
||||
summary: 280,
|
||||
traits: 12,
|
||||
trait: 40,
|
||||
voice: 280,
|
||||
messaging: 64,
|
||||
skills: 64,
|
||||
});
|
||||
|
||||
// Ruling 8 and R9: a hired agent can't hire or fire, whatever HIRE_ALLOWED says later.
|
||||
export const NEVER_HIRED = Object.freeze(["role.hire", "role.fire"]);
|
||||
// The role rails (section 3): an action that lets its holder decide for other agents.
|
||||
export const RESOLVE_PREFIX = "decision.resolve.";
|
||||
|
||||
export const FLAG_RESOLVE = "resolve-right";
|
||||
export const FLAG_ABOVE_CHIEF = "above-chief";
|
||||
|
||||
const SKILL_NAME = /^[A-Za-z0-9._-]+$/;
|
||||
const GITEA_SCOPE = /^(read|write):([a-z]+)$/;
|
||||
// A plugin's declared bare name: lower-case segments joined by dots. With the prefix it is
|
||||
// <plugin>.<name>.
|
||||
const DECLARED_NAME = /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*$/;
|
||||
const DECLARED_SCOPE = /^[a-z][a-z0-9_.:-]*$/;
|
||||
const NAME_MAX = 64;
|
||||
|
||||
const isPlain = (value) => {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
||||
const proto = Object.getPrototypeOf(value);
|
||||
return proto === Object.prototype || proto === null;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The registry.
|
||||
|
||||
const BUILT = new WeakSet();
|
||||
|
||||
// The first segment of every core action, tool and service. A plugin may not take one as its
|
||||
// name, so a prefixed plugin name can never read as a core name (git + push.protected).
|
||||
const RESERVED_PLUGIN_NAMES = Object.freeze([...new Set([
|
||||
...ACTIONS.map((a) => a.split(".")[0]), ...TOOLS, ...SERVICES, HOST_SERVICE, ...NETWORKS,
|
||||
])]);
|
||||
|
||||
function declaredList(value, where, pattern) {
|
||||
if (value === undefined) return [];
|
||||
if (!Array.isArray(value)) refuse(`${where} must be an array`);
|
||||
const seen = new Set();
|
||||
value.forEach((entry, i) => {
|
||||
if (typeof entry !== "string" || entry.length > NAME_MAX || !pattern.test(entry)) {
|
||||
refuse(`${where}[${i}] must be a name matching ${pattern}`);
|
||||
}
|
||||
if (seen.has(entry)) refuse(`${where}[${i}] repeats an earlier entry`);
|
||||
seen.add(entry);
|
||||
});
|
||||
return [...seen];
|
||||
}
|
||||
|
||||
// One plugin's declared set: { name, tools?: [name], actions?: [name], services?: [{ name, scopes:
|
||||
// [scope] }] }. Names are declared bare and registered with the plugin's prefix. A plugin action is
|
||||
// always routed (section 4), so it may not name a hard wait, and no declared name may reuse a core
|
||||
// name, bare or prefixed.
|
||||
function checkPlugin(plugin, i, core) {
|
||||
const where = `registry plugins[${i}]`;
|
||||
if (!isPlain(plugin)) refuse(`${where} must be a JSON object`);
|
||||
for (const key of Object.keys(plugin)) {
|
||||
if (!PLUGIN_KEYS.includes(key)) refuse(`${where} has a key outside ${PLUGIN_KEYS.join(", ")}`);
|
||||
}
|
||||
const { name } = plugin;
|
||||
if (typeof name !== "string" || name.length > 32 || !PLUGIN_NAME.test(name)) refuse(`${where}.name must match ${PLUGIN_NAME}`);
|
||||
if (RESERVED_PLUGIN_NAMES.includes(name)) refuse(`${where}.name ${JSON.stringify(name)} is a core name`);
|
||||
const full = (bare) => `${name}.${bare}`;
|
||||
const coreName = (bare) => core.has(bare) || core.has(full(bare));
|
||||
const tools = declaredList(plugin.tools, `${where}.tools`, DECLARED_NAME);
|
||||
tools.forEach((bare, j) => {
|
||||
if (coreName(bare)) refuse(`${where}.tools[${j}] reuses the core name ${JSON.stringify(core.has(bare) ? bare : full(bare))}`);
|
||||
});
|
||||
const actions = declaredList(plugin.actions, `${where}.actions`, DECLARED_NAME);
|
||||
actions.forEach((bare, j) => {
|
||||
if (GATED_ONLY.includes(bare)) refuse(`${where}.actions[${j}] declares ${bare}, a hard wait; a plugin action is always routed`);
|
||||
if (coreName(bare)) refuse(`${where}.actions[${j}] reuses the core name ${JSON.stringify(core.has(bare) ? bare : full(bare))}`);
|
||||
});
|
||||
if (plugin.services !== undefined && !Array.isArray(plugin.services)) refuse(`${where}.services must be an array`);
|
||||
const services = (plugin.services ?? []).map((service, j) => {
|
||||
const at = `${where}.services[${j}]`;
|
||||
if (!isPlain(service)) refuse(`${at} must be a JSON object`);
|
||||
for (const key of Object.keys(service)) {
|
||||
if (!PLUGIN_SERVICE_KEYS.includes(key)) refuse(`${at} has a key outside ${PLUGIN_SERVICE_KEYS.join(", ")}`);
|
||||
}
|
||||
const [bare] = declaredList([service.name], `${at}.name`, DECLARED_NAME);
|
||||
if (coreName(bare)) refuse(`${at}.name reuses the core name ${JSON.stringify(core.has(bare) ? bare : full(bare))}`);
|
||||
const scopes = declaredList(service.scopes, `${at}.scopes`, DECLARED_SCOPE);
|
||||
if (scopes.length === 0) refuse(`${at}.scopes must not be empty`);
|
||||
return { name: full(bare), scopes };
|
||||
});
|
||||
const names = [...tools, ...actions, ...services.map((s) => s.name.slice(name.length + 1))];
|
||||
const twice = names.find((n, j) => names.indexOf(n) !== j);
|
||||
if (twice !== undefined) refuse(`${where} declares ${JSON.stringify(full(twice))} twice`);
|
||||
return { name, tools: tools.map(full), actions: actions.map(full), services };
|
||||
}
|
||||
|
||||
// The registry checkComposition reads: the core set plus each plugin's declared set. The plugin
|
||||
// set is the caller's input and empty by default (PL1 passes the plugins enabled in the
|
||||
// organization). checkComposition accepts only a registry built here.
|
||||
export function buildRegistry({ plugins = [] } = {}) {
|
||||
if (!Array.isArray(plugins)) refuse("registry plugins must be an array");
|
||||
const core = new Set([...ACTIONS, ...TOOLS, ...SERVICES, HOST_SERVICE]);
|
||||
const declared = plugins.map((plugin, i) => checkPlugin(plugin, i, core));
|
||||
const pluginNames = declared.map((p) => p.name);
|
||||
const twice = pluginNames.find((n, j) => pluginNames.indexOf(n) !== j);
|
||||
if (twice !== undefined) refuse(`registry names plugin ${JSON.stringify(twice)} twice`);
|
||||
const services = Object.create(null);
|
||||
for (const name of SERVICES) services[name] = { kind: name };
|
||||
services[HOST_SERVICE] = { kind: HOST_SERVICE };
|
||||
for (const p of declared) for (const s of p.services) services[s.name] = { kind: "plugin", scopes: s.scopes };
|
||||
const pluginActions = declared.flatMap((p) => p.actions);
|
||||
const registry = deepFreeze({
|
||||
tools: [...TOOLS, ...declared.flatMap((p) => p.tools)],
|
||||
networks: [...NETWORKS],
|
||||
actions: [...ACTIONS, ...pluginActions],
|
||||
routed: [...ROUTED, ...pluginActions],
|
||||
services,
|
||||
plugins: declared,
|
||||
});
|
||||
BUILT.add(registry);
|
||||
return registry;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The validator.
|
||||
|
||||
function keysWithin(object, allowed, where) {
|
||||
Object.keys(object).forEach((key, i) => {
|
||||
if (!allowed.includes(key)) refuse(`composition ${where} has an unknown key (key ${i + 1}; allowed: ${allowed.join(", ")})`);
|
||||
});
|
||||
}
|
||||
|
||||
// A list of distinct strings. Entries are named by position only: this runs before anything is
|
||||
// known to be a registered name.
|
||||
function stringList(value, where, { nonEmpty = false } = {}) {
|
||||
if (!Array.isArray(value)) refuse(`composition ${where} must be an array`);
|
||||
if (nonEmpty && value.length === 0) refuse(`composition ${where} must not be empty`);
|
||||
const seen = new Set();
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
if (typeof value[i] !== "string" || value[i].includes("\0")) refuse(`composition ${where}[${i}] must be a string`);
|
||||
if (seen.has(value[i])) refuse(`composition ${where}[${i}] repeats an earlier entry`);
|
||||
seen.add(value[i]);
|
||||
}
|
||||
return [...seen];
|
||||
}
|
||||
|
||||
function text(value, where) {
|
||||
if (typeof value !== "string" || value.includes("\0")) refuse(`composition ${where} must be a string`);
|
||||
return value;
|
||||
}
|
||||
|
||||
// Step 1: the shape, with every unknown key refused. Returns a fresh copy; the input is not read
|
||||
// again after this.
|
||||
function shape(role) {
|
||||
if (!isPlain(role)) refuse("composition must be a JSON object");
|
||||
keysWithin(role, COMPOSITION_KEYS, "role");
|
||||
for (const key of REQUIRED) {
|
||||
if (role[key] === undefined) refuse(`composition requires "${key}"`);
|
||||
}
|
||||
const out = {};
|
||||
out.tools = stringList(role.tools, "tools", { nonEmpty: true });
|
||||
if (typeof role.network !== "string") refuse("composition network must be a string");
|
||||
out.network = role.network;
|
||||
|
||||
if (!isPlain(role.authority)) refuse("composition authority must be a JSON object");
|
||||
keysWithin(role.authority, AUTHORITY_KEYS, "authority");
|
||||
out.authority = {};
|
||||
for (const key of AUTHORITY_KEYS) out.authority[key] = stringList(role.authority[key], `authority.${key}`);
|
||||
out.authority.crossRole.forEach((action, i) => {
|
||||
if (out.authority.withinRole.includes(action)) refuse(`composition authority.crossRole[${i}] is also in authority.withinRole`);
|
||||
});
|
||||
|
||||
if (!Array.isArray(role.credentials)) refuse("composition credentials must be an array");
|
||||
out.credentials = role.credentials.map((entry, i) => {
|
||||
const where = `credentials[${i}]`;
|
||||
if (!isPlain(entry)) refuse(`composition ${where} must be a JSON object`);
|
||||
keysWithin(entry, CREDENTIAL_KEYS, where);
|
||||
if (typeof entry.service !== "string") refuse(`composition ${where}.service must be a string`);
|
||||
const copy = { service: entry.service };
|
||||
if (entry.scopes !== undefined) {
|
||||
if (Array.isArray(entry.scopes)) {
|
||||
copy.scopes = stringList(entry.scopes, `${where}.scopes`);
|
||||
} else if (isPlain(entry.scopes)) {
|
||||
// No prototype, so a "__proto__" group stays a key and step 2 refuses it.
|
||||
copy.scopes = Object.create(null);
|
||||
Object.keys(entry.scopes).forEach((group, j) => {
|
||||
if (group.includes("\0")) refuse(`composition ${where}.scopes key ${j + 1} must be a string`);
|
||||
copy.scopes[group] = stringList(entry.scopes[group], `${where}.scopes (group ${j + 1})`);
|
||||
});
|
||||
} else {
|
||||
refuse(`composition ${where}.scopes must be an array or a JSON object`);
|
||||
}
|
||||
}
|
||||
return copy;
|
||||
});
|
||||
|
||||
if (role.messaging === undefined) {
|
||||
out.messaging = null;
|
||||
} else {
|
||||
if (!isPlain(role.messaging)) refuse("composition messaging must be a JSON object");
|
||||
keysWithin(role.messaging, MESSAGING_KEYS, "messaging");
|
||||
out.messaging = {};
|
||||
for (const key of MESSAGING_KEYS) {
|
||||
out.messaging[key] = stringList(role.messaging[key], `messaging.${key}`);
|
||||
out.messaging[key].forEach((entry, i) => {
|
||||
if (!ID_PATTERN.test(entry)) refuse(`composition messaging.${key}[${i}] must be a role name matching ${ID_PATTERN}`);
|
||||
if (entry === "tester") refuse(`composition messaging.${key}[${i}] names the tester, which is never a role`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
out.skills = role.skills === undefined ? [] : stringList(role.skills, "skills");
|
||||
out.skills.forEach((name, i) => {
|
||||
if (!SKILL_NAME.test(name) || name === "." || name === "..") refuse(`composition skills[${i}] must be a skill directory name matching ${SKILL_NAME}`);
|
||||
});
|
||||
|
||||
out.instructions = text(role.instructions, "instructions");
|
||||
|
||||
if (role.personality === undefined) {
|
||||
out.personality = null;
|
||||
} else {
|
||||
if (!isPlain(role.personality)) refuse("composition personality must be a JSON object");
|
||||
keysWithin(role.personality, PERSONALITY_KEYS, "personality");
|
||||
out.personality = {};
|
||||
if (role.personality.summary !== undefined) out.personality.summary = text(role.personality.summary, "personality.summary");
|
||||
if (role.personality.traits !== undefined) out.personality.traits = stringList(role.personality.traits, "personality.traits");
|
||||
if (role.personality.voice !== undefined) out.personality.voice = text(role.personality.voice, "personality.voice");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function checkGitea(scopes, where) {
|
||||
if (!Array.isArray(scopes) || scopes.length === 0) refuse(`composition ${where}.scopes must be a non-empty array of read:<category> or write:<category>`);
|
||||
const categories = new Set();
|
||||
scopes.forEach((scope, i) => {
|
||||
const m = GITEA_SCOPE.exec(scope);
|
||||
if (!m || !GITEA_SCOPE_CATEGORIES.includes(m[2])) {
|
||||
refuse(`composition ${where}.scopes[${i}] is not a grantable gitea scope (categories: ${GITEA_SCOPE_CATEGORIES.join(", ")}; admin and all are never granted)`);
|
||||
}
|
||||
if (categories.has(m[2])) refuse(`composition ${where}.scopes[${i}] names the category ${m[2]} again; a token holds one level per category`);
|
||||
categories.add(m[2]);
|
||||
});
|
||||
}
|
||||
|
||||
function checkVikunja(scopes, where) {
|
||||
if (!isPlain(scopes) || Object.keys(scopes).length === 0) refuse(`composition ${where}.scopes must name at least one vikunja route group`);
|
||||
Object.keys(scopes).forEach((group, j) => {
|
||||
if (!Object.hasOwn(VIKUNJA_GRANTABLE, group)) refuse(`composition ${where}.scopes group ${j + 1} is not a grantable vikunja route group`);
|
||||
const verbs = scopes[group];
|
||||
if (verbs.length === 0) refuse(`composition ${where}.scopes.${group} must not be empty`);
|
||||
verbs.forEach((verb, i) => {
|
||||
if (!VIKUNJA_GRANTABLE[group].includes(verb)) refuse(`composition ${where}.scopes.${group}[${i}] is not a grantable vikunja verb`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function checkPluginScopes(scopes, declared, service, where) {
|
||||
if (!Array.isArray(scopes) || scopes.length === 0) refuse(`composition ${where}.scopes must be a non-empty array of ${service} scopes`);
|
||||
scopes.forEach((scope, i) => {
|
||||
if (!declared.includes(scope)) refuse(`composition ${where}.scopes[${i}] is not a scope ${service} declares`);
|
||||
});
|
||||
}
|
||||
|
||||
// Step 2: every action, tool, network, service and scope must be in the registry.
|
||||
function registered(c, registry) {
|
||||
c.tools.forEach((tool, i) => {
|
||||
if (!registry.tools.includes(tool)) refuse(`composition tools[${i}] names no registered tool`);
|
||||
});
|
||||
if (!registry.networks.includes(c.network)) refuse(`composition network must be one of: ${registry.networks.join(", ")}`);
|
||||
for (const key of AUTHORITY_KEYS) {
|
||||
c.authority[key].forEach((action, i) => {
|
||||
if (!registry.actions.includes(action)) refuse(`composition authority.${key}[${i}] names no registered action`);
|
||||
});
|
||||
}
|
||||
const seen = new Set();
|
||||
c.credentials.forEach((entry, i) => {
|
||||
const where = `credentials[${i}]`;
|
||||
if (!Object.hasOwn(registry.services, entry.service)) refuse(`composition ${where}.service names no registered credential service`);
|
||||
if (seen.has(entry.service)) refuse(`composition ${where} names ${entry.service} again`);
|
||||
seen.add(entry.service);
|
||||
const { kind, scopes } = registry.services[entry.service];
|
||||
if (kind === HOST_SERVICE) {
|
||||
if (entry.scopes !== undefined) refuse(`composition ${where} names ${HOST_SERVICE}, which takes no scopes`);
|
||||
} else if (kind === "gitea") {
|
||||
checkGitea(entry.scopes, where);
|
||||
} else if (kind === "vikunja") {
|
||||
checkVikunja(entry.scopes, where);
|
||||
} else {
|
||||
checkPluginScopes(entry.scopes, scopes, entry.service, where);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function eachAction(c, fn) {
|
||||
for (const key of AUTHORITY_KEYS) c.authority[key].forEach((action, i) => fn(action, key, i));
|
||||
}
|
||||
|
||||
// Every string in the composition with its path, for step 6.
|
||||
function strings(c) {
|
||||
const out = [];
|
||||
c.tools.forEach((v, i) => out.push([`tools[${i}]`, v]));
|
||||
out.push(["network", c.network]);
|
||||
eachAction(c, (v, key, i) => out.push([`authority.${key}[${i}]`, v]));
|
||||
c.credentials.forEach((entry, i) => {
|
||||
out.push([`credentials[${i}].service`, entry.service]);
|
||||
if (Array.isArray(entry.scopes)) entry.scopes.forEach((v, j) => out.push([`credentials[${i}].scopes[${j}]`, v]));
|
||||
else if (entry.scopes) {
|
||||
for (const [group, verbs] of Object.entries(entry.scopes)) {
|
||||
out.push([`credentials[${i}].scopes (group key)`, group]);
|
||||
verbs.forEach((v, j) => out.push([`credentials[${i}].scopes.${group}[${j}]`, v]));
|
||||
}
|
||||
}
|
||||
});
|
||||
if (c.messaging) for (const key of MESSAGING_KEYS) c.messaging[key].forEach((v, i) => out.push([`messaging.${key}[${i}]`, v]));
|
||||
c.skills.forEach((v, i) => out.push([`skills[${i}]`, v]));
|
||||
out.push(["instructions", c.instructions]);
|
||||
if (c.personality) {
|
||||
if (c.personality.summary !== undefined) out.push(["personality.summary", c.personality.summary]);
|
||||
(c.personality.traits ?? []).forEach((v, i) => out.push([`personality.traits[${i}]`, v]));
|
||||
if (c.personality.voice !== undefined) out.push(["personality.voice", c.personality.voice]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function bounded(value, max, where, { empty = false } = {}) {
|
||||
if ((!empty && value.trim().length === 0) || value.length > max) {
|
||||
refuse(`composition ${where} must be ${empty ? "" : "non-empty and "}at most ${max} characters`);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6, the bounds half.
|
||||
function checkBounds(c) {
|
||||
const b = COMPOSITION_BOUNDS;
|
||||
bounded(c.instructions, b.instructions, "instructions");
|
||||
if (c.personality) {
|
||||
if (c.personality.summary !== undefined) bounded(c.personality.summary, b.summary, "personality.summary");
|
||||
if (c.personality.voice !== undefined) bounded(c.personality.voice, b.voice, "personality.voice");
|
||||
const traits = c.personality.traits ?? [];
|
||||
if (traits.length > b.traits) refuse(`composition personality.traits must hold at most ${b.traits} entries`);
|
||||
traits.forEach((trait, i) => bounded(trait, b.trait, `personality.traits[${i}]`));
|
||||
}
|
||||
if (c.messaging) {
|
||||
for (const key of MESSAGING_KEYS) {
|
||||
if (c.messaging[key].length > b.messaging) refuse(`composition messaging.${key} must hold at most ${b.messaging} entries`);
|
||||
}
|
||||
}
|
||||
if (c.skills.length > b.skills) refuse(`composition skills must hold at most ${b.skills} entries`);
|
||||
}
|
||||
|
||||
// A plain JSON copy of a caller's object, read once. Getters, proxies and toJSON run here and only
|
||||
// here, and whatever they throw becomes the fixed message: their error text could carry role text.
|
||||
function snapshot(value, message) {
|
||||
let copy;
|
||||
try {
|
||||
copy = isPlain(value) ? JSON.parse(JSON.stringify(value)) : undefined;
|
||||
} catch {
|
||||
copy = undefined;
|
||||
}
|
||||
if (!isPlain(copy)) refuse(message);
|
||||
return copy;
|
||||
}
|
||||
|
||||
// The chief's resolved role, as loadRole returns it. Read only for step 7; a malformed one refuses.
|
||||
function checkChief(chief) {
|
||||
const bad = () => refuse("checkComposition needs the chief's resolved role");
|
||||
if (!isPlain(chief) || !Array.isArray(chief.tools) || !NETWORKS.includes(chief.network)) bad();
|
||||
if (!isPlain(chief.authority) || !AUTHORITY_KEYS.every((k) => Array.isArray(chief.authority[k]))) bad();
|
||||
if (!Array.isArray(chief.credentials) || !chief.credentials.every((c) => isPlain(c) && typeof c.service === "string")) bad();
|
||||
if (chief.messaging !== null && chief.messaging !== undefined &&
|
||||
!(isPlain(chief.messaging) && MESSAGING_KEYS.every((k) => Array.isArray(chief.messaging[k])))) bad();
|
||||
return chief;
|
||||
}
|
||||
|
||||
// One string per credential scope a role holds: service, then scope (a vikunja scope is
|
||||
// group.verb; host has the one line "host").
|
||||
function credentialLines(credentials) {
|
||||
const out = [];
|
||||
for (const { service, scopes } of credentials) {
|
||||
if (scopes === undefined) out.push([service, service]);
|
||||
else if (Array.isArray(scopes)) for (const scope of scopes) out.push([service, scope]);
|
||||
else for (const [group, verbs] of Object.entries(scopes)) for (const verb of verbs) out.push([service, `${group}.${verb}`]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Validate a composition (section 4's seven steps, in order) against a registry from
|
||||
// buildRegistry, with the chief's resolved role for step 7. options.assertClean is the credential
|
||||
// store's check (credentials.assertClean, as the broker's secretCheck); it throws on a held
|
||||
// secret. Returns a frozen { composition, flagged }, where each flagged line is
|
||||
// { field, value, reasons } and reasons holds "resolve-right" (step 5), "above-chief" (step 7) or
|
||||
// both. A composition that fails a step refuses with an OrganizationConfigError.
|
||||
export function checkComposition(role, registry, chief, { assertClean } = {}) {
|
||||
if (!BUILT.has(registry)) refuse("checkComposition needs a registry from buildRegistry");
|
||||
if (typeof assertClean !== "function") refuse("checkComposition needs the credential store's assertClean");
|
||||
const boss = checkChief(snapshot(chief, "checkComposition needs the chief's resolved role"));
|
||||
|
||||
// 1. Unknown keys, and the shape.
|
||||
const c = shape(snapshot(role, "composition must be a JSON object"));
|
||||
|
||||
// 2. Every name in the registry.
|
||||
registered(c, registry);
|
||||
|
||||
// 3. Hard waits stay with the human (R4). From here on an action is a registered name and may be
|
||||
// quoted.
|
||||
eachAction(c, (action, key, i) => {
|
||||
if (!registry.routed.includes(action)) refuse(`composition authority.${key}[${i}] is ${action}, a hard wait the human always decides`);
|
||||
});
|
||||
|
||||
// 4. No hire chains (R9).
|
||||
eachAction(c, (action, key, i) => {
|
||||
if (NEVER_HIRED.includes(action)) refuse(`composition authority.${key}[${i}] is ${action}; a hired agent cannot hire or fire`);
|
||||
});
|
||||
|
||||
// 5. Every resolve right is a flagged line.
|
||||
const lines = [];
|
||||
const flag = (field, value, reason) => {
|
||||
const line = lines.find((l) => l.field === field && l.value === value);
|
||||
if (line) {
|
||||
if (!line.reasons.includes(reason)) line.reasons.push(reason);
|
||||
} else {
|
||||
lines.push({ field, value, reasons: [reason] });
|
||||
}
|
||||
};
|
||||
eachAction(c, (action, key) => {
|
||||
if (action.startsWith(RESOLVE_PREFIX)) flag(`authority.${key}`, action, FLAG_RESOLVE);
|
||||
});
|
||||
|
||||
// 6. No secret in any string (R1), and every field within its bound.
|
||||
for (const [where, value] of strings(c)) {
|
||||
let clean = true;
|
||||
try {
|
||||
assertClean(value);
|
||||
} catch {
|
||||
clean = false;
|
||||
}
|
||||
if (!clean) refuse(`composition ${where} holds a credential`);
|
||||
}
|
||||
checkBounds(c);
|
||||
|
||||
// 7. Everything the chief's own role does not hold (R8). A within-role action is held only
|
||||
// within-role; a cross-role one either way.
|
||||
const chiefWithin = boss.authority.withinRole;
|
||||
const chiefAny = [...chiefWithin, ...boss.authority.crossRole];
|
||||
c.authority.withinRole.forEach((action) => {
|
||||
if (!chiefWithin.includes(action)) flag("authority.withinRole", action, FLAG_ABOVE_CHIEF);
|
||||
});
|
||||
c.authority.crossRole.forEach((action) => {
|
||||
if (!chiefAny.includes(action)) flag("authority.crossRole", action, FLAG_ABOVE_CHIEF);
|
||||
});
|
||||
c.tools.forEach((tool) => {
|
||||
if (!boss.tools.includes(tool)) flag("tools", tool, FLAG_ABOVE_CHIEF);
|
||||
});
|
||||
if (NETWORKS.indexOf(c.network) > NETWORKS.indexOf(boss.network)) flag("network", c.network, FLAG_ABOVE_CHIEF);
|
||||
const held = new Set(credentialLines(boss.credentials).map(([s, v]) => `${s}\n${v}`));
|
||||
for (const [service, value] of credentialLines(c.credentials)) {
|
||||
if (!held.has(`${service}\n${value}`)) flag(`credentials.${service}`, value, FLAG_ABOVE_CHIEF);
|
||||
}
|
||||
// Both directions: the broker checks the sender's sendTo and the recipient's receiveFrom. A role
|
||||
// with no messaging key may message and hear anyone; "*" stands for that.
|
||||
if (boss.messaging) {
|
||||
for (const key of MESSAGING_KEYS) {
|
||||
const chiefList = boss.messaging[key];
|
||||
if (c.messaging === null) flag(`messaging.${key}`, "*", FLAG_ABOVE_CHIEF);
|
||||
else c.messaging[key].forEach((target) => {
|
||||
if (!chiefList.includes(target)) flag(`messaging.${key}`, target, FLAG_ABOVE_CHIEF);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const line of lines) line.reasons.sort();
|
||||
return deepFreeze({ composition: c, flagged: lines });
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { dirname, isAbsolute, join, normalize, parse, resolve } from 'node:path'
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { processState } from '../../seat/src/process-state.mjs';
|
||||
import { ID_PATTERN } from './vocabulary.mjs';
|
||||
import { parseJsonFileText } from './util.mjs';
|
||||
|
||||
export class OrgError extends Error {
|
||||
constructor(code, message, path) { super(message); this.name = 'OrgError'; this.code = code; if (path) this.path = path; }
|
||||
@@ -23,7 +22,7 @@ async function safeDir(path, create = false) {
|
||||
}
|
||||
async function readPrivate(file) {
|
||||
const fd = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
try { const st = await fd.stat(); if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) fail('org-mismatch', 'unsafe metadata file', file); return parseJsonFileText(await fd.readFile('utf8'), file, 'metadata file'); } finally { await fd.close(); }
|
||||
try { const st = await fd.stat(); if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) fail('org-mismatch', 'unsafe metadata file', file); return JSON.parse(await fd.readFile('utf8')); } finally { await fd.close(); }
|
||||
}
|
||||
async function privateDir(path) { const st = await fs.lstat(path); if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe private directory', path); }
|
||||
function validateRegistry(doc, file) {
|
||||
@@ -87,7 +86,7 @@ export function withRegistryWriter(configDir, fn) {
|
||||
if (!dir.isDirectory() || dir.isSymbolicLink() || dir.uid !== process.getuid() || (dir.mode & 0o077)) fail('org-mismatch', 'unsafe private directory', configDir);
|
||||
const st = fstatSync(fd);
|
||||
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) fail('org-mismatch', 'unsafe metadata file', file);
|
||||
registry = validateRegistry(parseJsonFileText(readFileSync(fd, 'utf8'), file, 'registry file'), file);
|
||||
registry = validateRegistry(JSON.parse(readFileSync(fd, 'utf8')), file);
|
||||
} finally { closeSync(fd); }
|
||||
}
|
||||
const publish = () => {
|
||||
|
||||
@@ -10,7 +10,6 @@ export const RIGHTS = record({
|
||||
'board.read': flag(true),
|
||||
'task.write': flag(true),
|
||||
decide: flag(true),
|
||||
'org.hire': flag(true),
|
||||
'request.write': flag(false),
|
||||
'members.manage': flag(false),
|
||||
'org.setup': flag(false),
|
||||
@@ -66,7 +65,6 @@ export const VERB_RIGHTS = record({
|
||||
'tasks.delete': null,
|
||||
'decision.preview.console': 'decide',
|
||||
'decision.resolve.console': 'decide',
|
||||
'hire.resolve.console': 'org.hire',
|
||||
'request.answer.console': 'request.write',
|
||||
'request.attach.console': 'request.write',
|
||||
'request.cancel.console': 'request.write',
|
||||
|
||||
@@ -9,7 +9,6 @@ import { ID_PATTERN } from './vocabulary.mjs';
|
||||
import { OrgError, orgRoot, readRegistry, reserveOrg, resolveOrg, withRegistry, withOrgLifecycle } from './org-registry.mjs';
|
||||
import { loadInstalled } from '../../plugins/src/loader.mjs';
|
||||
import { BINDING_NAME } from '../../discord/src/binding.mjs';
|
||||
import { parseJsonFileText } from './util.mjs';
|
||||
const rolesDir = resolve(dirname(fileURLToPath(import.meta.url)), '../../../roles');
|
||||
const owner = Object.freeze({ provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' });
|
||||
const fail = (code, message, path) => { throw new OrgError(code, message, path); };
|
||||
@@ -88,8 +87,7 @@ export async function templateRender(id, { fixture, preset, thinking } = {}) {
|
||||
if (fixture !== undefined && preset !== undefined) fail('org-invalid', 'choose fixture or preset');
|
||||
if (fixture === 'local') preset = 'fixture';
|
||||
checkId(id);
|
||||
const template = new URL('./org-template.json', import.meta.url);
|
||||
const doc = parseJsonFileText(await fs.readFile(template, 'utf8'), fileURLToPath(template), 'organization template file');
|
||||
const doc = JSON.parse(await fs.readFile(new URL('./org-template.json', import.meta.url), 'utf8'));
|
||||
doc.id = id; doc.projects.work.root = `/__mosaic_org__/${id}/projects/work`;
|
||||
if (preset !== undefined) {
|
||||
if (typeof preset !== 'string') fail('org-invalid', 'unknown preset');
|
||||
@@ -128,14 +126,13 @@ async function readCurrentUnlocked(ctx, id, { includeArchived = false } = {}) {
|
||||
const found = await resolveOrg(ctx, id);
|
||||
if (found.state === 'archived' && !includeArchived) fail('org-archived', 'organization is archived');
|
||||
if (found.state !== 'active' && !(includeArchived && found.state === 'archived')) fail('org-unavailable', 'organization is not active');
|
||||
const ownerFile = join(found.root, 'owner.json');
|
||||
const principal = parseJsonFileText(await privateRead(ownerFile), ownerFile, 'organization owner file'); if (!same(principal, ctx.principal)) fail('org-forbidden', 'organization owner required');
|
||||
const principal = JSON.parse(await privateRead(join(found.root, 'owner.json'))); if (!same(principal, ctx.principal)) fail('org-forbidden', 'organization owner required');
|
||||
// From here the caller is the verified owner. A failure carries that proof so a list may
|
||||
// show this org as broken to its owner; earlier failures prove nothing and stay hidden.
|
||||
try {
|
||||
const entry = (await readRegistry(ctx)).orgs[id]; const file = join(ctx.configDir, 'businesses', `${id}.json`); const raw = await privateRead(file);
|
||||
const entry = (await readRegistry(ctx)).orgs[id]; const raw = await privateRead(join(ctx.configDir, 'businesses', `${id}.json`));
|
||||
if (!entry.digest || entry.digest !== digest(raw)) fail('org-mismatch', 'organization document and registry disagree');
|
||||
const doc = parseJsonFileText(raw, file, 'organization file'); await checked(doc); return { ...found, revision: entry.revision, doc, principal };
|
||||
const doc = JSON.parse(raw); await checked(doc); return { ...found, revision: entry.revision, doc, principal };
|
||||
} catch (e) { if (e && typeof e === 'object') e.ownerVerified = true; throw e; }
|
||||
}
|
||||
// Readers do not take the writer lock. Require unchanged registry snapshots and
|
||||
@@ -177,7 +174,7 @@ export async function notificationShow(ctx, id) {
|
||||
const file = join(current.root, 'notify', id, 'notify.json');
|
||||
const st = await fs.lstat(file);
|
||||
if (!st.isFile() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) fail('org-mismatch', 'unsafe notifier configuration');
|
||||
const binding = parseJsonFileText(await privateRead(file), file, 'notifier configuration file');
|
||||
const binding = JSON.parse(await privateRead(file));
|
||||
if (!binding || Object.keys(binding).sort().join(',') !== 'binding,notifyVersion' || binding.notifyVersion !== 1 || !(binding.binding === null || typeof binding.binding === 'string' && BINDING_NAME.test(binding.binding))) fail('org-mismatch', 'invalid notifier configuration');
|
||||
const record = (await receipts(current.root)).find(r => r.op === 'org.create' && r.org === id && same(r.subject, current.principal));
|
||||
if (!record || record.notifications !== 'none') fail('org-mismatch', 'notification choice receipt unavailable');
|
||||
@@ -328,7 +325,7 @@ export async function deleteOrg(ctx, id, { confirm, ifRevision, busStop } = {})
|
||||
try {
|
||||
await deleteSafeRoot(ctx.dataRoot);
|
||||
await ensurePrivateDir(deleted); await ensurePrivateDir(destination);
|
||||
try { result = parseJsonFileText(await privateRead(receiptFile), receiptFile, 'deletion receipt file'); }
|
||||
try { result = JSON.parse(await privateRead(receiptFile)); }
|
||||
catch (e) { if (e.code !== 'ENOENT') throw e; result = { op: 'org.delete', org: id, actor: deletion.actor, time: deletion.time, revision: deletion.revision, destination, bus: null, moves: [], secrets: [], note: 'Purging deleted data and remote-side revocation are out of scope.' }; await atomic(receiptFile, encode(result), true); }
|
||||
if (result.org !== id || result.destination !== destination || result.revision !== deletion.revision || !Array.isArray(result.moves) || !Array.isArray(result.secrets)) fail('org-mismatch', 'invalid deletion receipt');
|
||||
if (!result.bus) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import { join, dirname } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { businessFilePath, validateBusinessDocument } from "./organization-config.mjs";
|
||||
import { requireId, sha256, canonicalJson, parseJsonFileText } from "./util.mjs";
|
||||
import { requireId, sha256, canonicalJson } from "./util.mjs";
|
||||
import { isDailyModelQuota } from "../../plugins/src/model-quota.mjs";
|
||||
import { checkReplyLimits } from "../../library/src/limits.mjs";
|
||||
import { ADDITION_VERB, additionProblem } from "./project-bootstrap.mjs";
|
||||
@@ -153,7 +153,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
function read(id) {
|
||||
const file = businessFilePath(id, dir);
|
||||
const bytes = regular(file);
|
||||
const document = parseJsonFileText(bytes, file, "organization file");
|
||||
const document = JSON.parse(bytes);
|
||||
validateBusinessDocument(document, file, { rolesDir });
|
||||
return { document, revision: sha256(bytes) };
|
||||
}
|
||||
@@ -243,7 +243,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
throw e;
|
||||
}
|
||||
if (text && !text.endsWith("\n")) fail("organization-uncertain", "torn organization receipt");
|
||||
const entries = text.trim() ? text.trim().split("\n").map(line => parseJsonFileText(line, logFile(id), "organization receipt file")) : [];
|
||||
const entries = text.trim() ? text.trim().split("\n").map(JSON.parse) : [];
|
||||
const pending = new Map();
|
||||
for (const e of entries) {
|
||||
if (e.state === "prepared") pending.set(e.transaction, e);
|
||||
@@ -287,7 +287,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
fail('org-mismatch', 'organization document and registry disagree');
|
||||
}
|
||||
if (revision !== before) fail("revision-conflict", "organization revision changed; read again");
|
||||
const old = parseJsonFileText(oldBytes, file, "organization file");
|
||||
const old = JSON.parse(oldBytes);
|
||||
if (typeof document === "function") document = document(old);
|
||||
guard(old, document);
|
||||
replyLimitsFence(old, document, actor);
|
||||
@@ -421,7 +421,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
}
|
||||
if (text && !text.endsWith("\n")) fail("organization-uncertain", "torn organization receipt");
|
||||
const last = new Map();
|
||||
for (const e of text.trim() ? text.trim().split("\n").map(line => parseJsonFileText(line, logFile(id), "organization receipt file")) : []) last.set(e.transaction, e);
|
||||
for (const e of text.trim() ? text.trim().split("\n").map(JSON.parse) : []) last.set(e.transaction, e);
|
||||
const hash = sha256(regular(businessFilePath(id, dir)));
|
||||
for (const e of last.values()) {
|
||||
const d = e.detail;
|
||||
|
||||
@@ -58,16 +58,6 @@ export function requireDate(value, where) {
|
||||
return value;
|
||||
}
|
||||
|
||||
// Parser diagnostics can quote file bytes. Keep the existing SyntaxError refusal,
|
||||
// but never attach the parser's message or cause to an error that callers can log.
|
||||
export function parseJsonFileText(text, file, what = "file") {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
throw new SyntaxError(`${what} is not valid JSON (${file})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Read a JSON file that must be a regular file, not a symbolic link.
|
||||
// Missing or not a regular file is 4; unparseable is 2. One descriptor,
|
||||
// opened without following a link, serves every check and the read.
|
||||
@@ -91,9 +81,9 @@ export function readJsonFile(file, what, checkStat) {
|
||||
closeSync(fd);
|
||||
}
|
||||
try {
|
||||
return parseJsonFileText(text, file, what);
|
||||
} catch {
|
||||
refuse(`${what} is not valid JSON (${file})`);
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
refuse(`${what} is not valid JSON (${file}): ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,318 +0,0 @@
|
||||
// Roles round 2, slice P1 (#1873): the composition validator and its registry.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { loadRole, BusinessError } from "../src/index.mjs";
|
||||
import { GATED_ONLY, HIRE_ALLOWED, unhireableAction, ROUTED } from "../src/vocabulary.mjs";
|
||||
import {
|
||||
buildRegistry, checkComposition, COMPOSITION_BOUNDS, COMPOSITION_KEYS, FLAG_RESOLVE, FLAG_ABOVE_CHIEF,
|
||||
} from "../src/composition.mjs";
|
||||
import { REPO_ROLES } from "./helpers.mjs";
|
||||
|
||||
const SECRET = "sk-test-NOT-A-SECRET-1234";
|
||||
// Stands in for the bus credential store's assertClean. Its own error quotes the secret, so a test
|
||||
// can tell if checkComposition ever passes that error on.
|
||||
const assertClean = (value) => {
|
||||
if (JSON.stringify(value).includes(SECRET)) throw new Error(`credential-leak: ${SECRET}`);
|
||||
};
|
||||
const OPTS = { assertClean };
|
||||
const CHIEF = loadRole(REPO_ROLES, "chief-of-staff");
|
||||
const CORE = buildRegistry();
|
||||
|
||||
function base(overrides = {}) {
|
||||
return {
|
||||
tools: ["read"],
|
||||
network: "none",
|
||||
authority: { withinRole: ["message.send"], crossRole: [] },
|
||||
credentials: [{ service: "gitea", scopes: ["read:repository"] }],
|
||||
instructions: "Do the work in the brief.",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const run = (role, registry = CORE, chief = CHIEF) => checkComposition(role, registry, chief, OPTS);
|
||||
|
||||
function refuses(fn, pattern, ...absent) {
|
||||
assert.throws(fn, (error) => {
|
||||
assert.ok(error instanceof BusinessError, `expected BusinessError, got ${error}`);
|
||||
assert.equal(error.exitCode, 2);
|
||||
assert.match(error.message, pattern);
|
||||
for (const text of [SECRET, ...absent]) {
|
||||
assert.ok(!error.message.includes(text), `the refusal quotes ${JSON.stringify(text)}: ${error.message}`);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
test("a minimal composition passes with nothing flagged", () => {
|
||||
const { composition, flagged } = run(base());
|
||||
assert.deepEqual(flagged, []);
|
||||
assert.equal(composition.instructions, "Do the work in the brief.");
|
||||
assert.equal(composition.messaging, null);
|
||||
assert.equal(composition.personality, null);
|
||||
assert.ok(Object.isFrozen(composition) && Object.isFrozen(composition.authority.withinRole));
|
||||
});
|
||||
|
||||
test("checkComposition needs a registry from buildRegistry and the store's assertClean", () => {
|
||||
refuses(() => checkComposition(base(), { ...CORE }, CHIEF, OPTS), /registry from buildRegistry/);
|
||||
refuses(() => checkComposition(base(), CORE, CHIEF), /assertClean/);
|
||||
refuses(() => checkComposition(base(), CORE, CHIEF, { assertClean: true }), /assertClean/);
|
||||
refuses(() => checkComposition(base(), CORE, { tools: [] }, OPTS), /chief's resolved role/);
|
||||
});
|
||||
|
||||
// Step 1.
|
||||
test("an unknown key refuses at every level, without quoting the key", () => {
|
||||
const key = `notes ${SECRET}`;
|
||||
refuses(() => run({ ...base(), [key]: 1 }), /role has an unknown key/, key);
|
||||
for (const field of ["name", "title", "contract", "roleVersion", "hireable"]) {
|
||||
refuses(() => run({ ...base(), [field]: "x" }), /role has an unknown key/);
|
||||
}
|
||||
refuses(() => run(base({ authority: { withinRole: [], crossRole: [], [key]: [] } })), /authority has an unknown key/, key);
|
||||
refuses(() => run(base({ credentials: [{ service: "gitea", scopes: ["read:issue"], [key]: 1 }] })), /credentials\[0\] has an unknown key/, key);
|
||||
refuses(() => run(base({ messaging: { sendTo: [], receiveFrom: [], [key]: [] } })), /messaging has an unknown key/, key);
|
||||
refuses(() => run(base({ personality: { summary: "Calm.", [key]: "x" } })), /personality has an unknown key/, key);
|
||||
refuses(() => run(JSON.parse('{"__proto__": {"tools": ["read"]}}')), /unknown key|requires/);
|
||||
});
|
||||
|
||||
test("the required primitives and their shapes", () => {
|
||||
for (const key of ["tools", "network", "authority", "credentials", "instructions"]) {
|
||||
const role = base();
|
||||
delete role[key];
|
||||
refuses(() => run(role), new RegExp(`requires "${key}"`));
|
||||
}
|
||||
refuses(() => run(base({ tools: [] })), /tools must not be empty/);
|
||||
refuses(() => run(base({ tools: ["read", "read"] })), /tools\[1\] repeats/);
|
||||
refuses(() => run(base({ authority: { withinRole: ["message.send"] } })), /authority.crossRole must be an array/);
|
||||
refuses(() => run(base({ authority: { withinRole: ["message.send"], crossRole: ["message.send"] } })), /crossRole\[0\] is also in authority.withinRole/);
|
||||
refuses(() => run(base({ instructions: ["x"] })), /instructions must be a string/);
|
||||
refuses(() => run(base({ instructions: "a\0b" })), /instructions must be a string/);
|
||||
refuses(() => run(base({ messaging: { sendTo: ["tester"], receiveFrom: [] } })), /names the tester/);
|
||||
refuses(() => run(base({ messaging: { sendTo: ["Bad Name"], receiveFrom: [] } })), /sendTo\[0\] must be a role name/, "Bad Name");
|
||||
refuses(() => run(base({ skills: [".."] })), /skills\[0\] must be a skill directory name/);
|
||||
assert.deepEqual(Object.keys(base()).every((k) => COMPOSITION_KEYS.includes(k)), true);
|
||||
});
|
||||
|
||||
// Step 2.
|
||||
test("an unknown action, tool, network, service or scope refuses without quoting it", () => {
|
||||
const odd = `zz-${SECRET}`;
|
||||
refuses(() => run(base({ authority: { withinRole: [odd], crossRole: [] } })), /withinRole\[0\] names no registered action/, odd);
|
||||
refuses(() => run(base({ authority: { withinRole: [], crossRole: ["task.nonesuch"] } })), /crossRole\[0\] names no registered action/, "task.nonesuch");
|
||||
refuses(() => run(base({ tools: ["read", "hammer"] })), /tools\[1\] names no registered tool/, "hammer");
|
||||
refuses(() => run(base({ network: odd })), /network must be one of: none, api-only, open/, odd);
|
||||
refuses(() => run(base({ credentials: [{ service: "jira", scopes: ["read"] }] })), /credentials\[0\].service names no registered/, "jira");
|
||||
refuses(() => run(base({ credentials: [{ service: "gitea", scopes: ["write:nonesuch"] }] })), /scopes\[0\] is not a grantable gitea scope/, "nonesuch");
|
||||
refuses(() => run(base({ credentials: [{ service: "vikunja", scopes: { nonesuch: ["read_one"] } }] })), /group 1 is not a grantable vikunja route group/, "nonesuch");
|
||||
refuses(() => run(base({ credentials: [{ service: "vikunja", scopes: { __proto__: null, ["__proto__"]: ["read_one"] } }] })), /not a grantable vikunja route group/);
|
||||
refuses(() => run(base({ credentials: [{ service: "vikunja", scopes: { tasks: ["obliterate"] } }] })), /scopes.tasks\[0\] is not a grantable vikunja verb/, "obliterate");
|
||||
refuses(() => run(base({ credentials: [{ service: "host", scopes: ["any"] }] })), /host, which takes no scopes/);
|
||||
refuses(() => run(base({ credentials: [{ service: "gitea", scopes: ["read:issue"] }, { service: "gitea", scopes: ["read:user"] }] })), /credentials\[1\] names gitea again/);
|
||||
});
|
||||
|
||||
test("gitea admin and all refuse, at either level", () => {
|
||||
for (const scope of ["admin", "all", "write:admin", "read:admin", "write:all", "read:all", "sudo"]) {
|
||||
refuses(() => run(base({ credentials: [{ service: "gitea", scopes: [scope] }] })), /not a grantable gitea scope/);
|
||||
}
|
||||
refuses(() => run(base({ credentials: [{ service: "gitea", scopes: ["read:issue", "write:issue"] }] })), /scopes\[1\] names the category issue again/);
|
||||
refuses(() => run(base({ credentials: [{ service: "gitea", scopes: [] }] })), /non-empty array/);
|
||||
});
|
||||
|
||||
// Step 3.
|
||||
test("each hard wait refuses, in either authority list", () => {
|
||||
assert.ok(GATED_ONLY.length > 0);
|
||||
for (const action of GATED_ONLY) {
|
||||
refuses(() => run(base({ authority: { withinRole: [action], crossRole: [] } })), /withinRole\[0\] is .* a hard wait/);
|
||||
refuses(() => run(base({ authority: { withinRole: [], crossRole: [action] } })), /crossRole\[0\] is .* a hard wait/);
|
||||
}
|
||||
});
|
||||
|
||||
// Step 4.
|
||||
test("role.hire and role.fire refuse", () => {
|
||||
for (const action of ["role.hire", "role.fire"]) {
|
||||
assert.ok(ROUTED.includes(action), `${action} is routed, so step 4 is what refuses it`);
|
||||
refuses(() => run(base({ authority: { withinRole: [action], crossRole: [] } })), /cannot hire or fire/);
|
||||
refuses(() => run(base({ authority: { withinRole: [], crossRole: [action] } })), /cannot hire or fire/);
|
||||
}
|
||||
});
|
||||
|
||||
// Step 5.
|
||||
test("a decision.resolve action comes back as a flagged line", () => {
|
||||
const { flagged } = run(base({ authority: { withinRole: ["message.send"], crossRole: ["decision.resolve.technical"] } }));
|
||||
assert.deepEqual(flagged, [
|
||||
{ field: "authority.crossRole", value: "decision.resolve.technical", reasons: [FLAG_ABOVE_CHIEF, FLAG_RESOLVE] },
|
||||
]);
|
||||
// Held by the chief: still flagged as a resolve right, and only that.
|
||||
const chief = { ...CHIEF, authority: { withinRole: [...CHIEF.authority.withinRole, "decision.resolve.technical"], crossRole: [] } };
|
||||
const held = run(base({ authority: { withinRole: ["decision.resolve.technical"], crossRole: [] } }), CORE, chief);
|
||||
assert.deepEqual(held.flagged, [{ field: "authority.withinRole", value: "decision.resolve.technical", reasons: [FLAG_RESOLVE] }]);
|
||||
});
|
||||
|
||||
// Step 6.
|
||||
test("a secret in instructions or personality refuses, and the refusal does not contain it", () => {
|
||||
const cases = [
|
||||
[{ instructions: `Use ${SECRET} for the API.` }, /instructions holds a credential/],
|
||||
[{ personality: { summary: `Knows ${SECRET}.` } }, /personality.summary holds a credential/],
|
||||
[{ personality: { traits: ["calm", `key ${SECRET}`] } }, /personality.traits\[1\] holds a credential/],
|
||||
[{ personality: { voice: `Says ${SECRET} often.` } }, /personality.voice holds a credential/],
|
||||
[{ skills: [SECRET] }, /skills\[0\] holds a credential/],
|
||||
];
|
||||
for (const [overrides, pattern] of cases) refuses(() => run(base(overrides)), pattern, "Use ", "Knows", "Says");
|
||||
});
|
||||
|
||||
test("the free text never reaches an error", () => {
|
||||
const prose = "PRIVATE-INSTRUCTION-TEXT";
|
||||
refuses(() => run(base({ instructions: prose.repeat(2000) })), /instructions must be non-empty and at most 16384 characters/, prose);
|
||||
refuses(() => run(base({ instructions: prose, personality: { summary: prose.repeat(20) } })), /personality.summary must be/, prose);
|
||||
refuses(() => run(base({ instructions: prose, authority: { withinRole: ["deploy"], crossRole: [] } })), /hard wait/, prose);
|
||||
});
|
||||
|
||||
test("the length bounds hold, from one exported constant", () => {
|
||||
assert.deepEqual({ ...COMPOSITION_BOUNDS }, { instructions: 16384, summary: 280, traits: 12, trait: 40, voice: 280, messaging: 64, skills: 64 });
|
||||
assert.ok(Object.isFrozen(COMPOSITION_BOUNDS));
|
||||
const b = COMPOSITION_BOUNDS;
|
||||
run(base({ instructions: "x".repeat(b.instructions), personality: { summary: "s".repeat(b.summary), voice: "v".repeat(b.voice), traits: Array.from({ length: b.traits }, (_, i) => `${i}`.padEnd(b.trait, "t")) } }));
|
||||
refuses(() => run(base({ instructions: "x".repeat(b.instructions + 1) })), /instructions must be/);
|
||||
refuses(() => run(base({ instructions: " " })), /instructions must be non-empty/);
|
||||
refuses(() => run(base({ personality: { summary: "s".repeat(b.summary + 1) } })), /personality.summary must be/);
|
||||
refuses(() => run(base({ personality: { voice: "v".repeat(b.voice + 1) } })), /personality.voice must be/);
|
||||
refuses(() => run(base({ personality: { traits: Array.from({ length: b.traits + 1 }, (_, i) => `t${i}`) } })), /at most 12 entries/);
|
||||
refuses(() => run(base({ personality: { traits: ["t".repeat(b.trait + 1)] } })), /traits\[0\] must be/);
|
||||
refuses(() => run(base({ messaging: { sendTo: Array.from({ length: b.messaging + 1 }, (_, i) => `r${i}`), receiveFrom: [] } })), /sendTo must hold at most 64/);
|
||||
refuses(() => run(base({ skills: Array.from({ length: b.skills + 1 }, (_, i) => `s${i}`) })), /skills must hold at most 64/);
|
||||
});
|
||||
|
||||
test("the steps run in order: a hard wait refuses before the secret check", () => {
|
||||
refuses(() => run(base({ instructions: SECRET, authority: { withinRole: ["spend"], crossRole: [] } })), /hard wait/);
|
||||
refuses(() => run(base({ instructions: SECRET, authority: { withinRole: ["role.fire"], crossRole: [] } })), /cannot hire or fire/);
|
||||
});
|
||||
|
||||
// Step 7.
|
||||
test("the shipped coder against the shipped chief flags exactly the hand-built set", () => {
|
||||
const coder = loadRole(REPO_ROLES, "coder");
|
||||
const role = {
|
||||
tools: coder.tools,
|
||||
network: coder.network,
|
||||
authority: coder.authority,
|
||||
credentials: coder.credentials,
|
||||
skills: coder.skills,
|
||||
instructions: readFileSync(join(REPO_ROLES, "coder.md"), "utf8"),
|
||||
};
|
||||
const { flagged } = run(role);
|
||||
const above = (field, value) => ({ field, value, reasons: [FLAG_ABOVE_CHIEF] });
|
||||
const expected = [
|
||||
above("authority.withinRole", "task.update.assigned"),
|
||||
above("authority.withinRole", "git.push.working"),
|
||||
above("authority.withinRole", "review.request"),
|
||||
above("authority.crossRole", "task.scope.change"),
|
||||
above("tools", "write"),
|
||||
above("tools", "edit"),
|
||||
above("tools", "bash"),
|
||||
above("credentials.gitea", "write:issue"),
|
||||
above("credentials.gitea", "write:repository"),
|
||||
above("credentials.vikunja", "tasks.read_one"),
|
||||
above("credentials.vikunja", "tasks.update"),
|
||||
above("credentials.vikunja", "tasks_comments.create"),
|
||||
above("credentials.vikunja", "projects.views_buckets_tasks"),
|
||||
];
|
||||
const key = (l) => `${l.field} ${l.value}`;
|
||||
assert.deepEqual([...flagged].map((l) => ({ ...l, reasons: [...l.reasons] })).sort((a, b) => key(a).localeCompare(key(b))),
|
||||
expected.sort((a, b) => key(a).localeCompare(key(b))));
|
||||
});
|
||||
|
||||
test("network, cross-role and messaging above the chief are flagged", () => {
|
||||
assert.deepEqual(run(base({ network: "open" })).flagged, [{ field: "network", value: "open", reasons: [FLAG_ABOVE_CHIEF] }]);
|
||||
assert.deepEqual(run(base({ network: "api-only" })).flagged, []);
|
||||
// A cross-role action the chief holds within its role is not above it.
|
||||
assert.deepEqual(run(base({ authority: { withinRole: [], crossRole: ["message.send"] } })).flagged, []);
|
||||
// A within-role action the chief holds only cross-role is.
|
||||
const crossChief = { ...CHIEF, authority: { withinRole: ["message.send"], crossRole: ["record.write"] } };
|
||||
assert.deepEqual(run(base({ authority: { withinRole: ["record.write"], crossRole: [] } }), CORE, crossChief).flagged,
|
||||
[{ field: "authority.withinRole", value: "record.write", reasons: [FLAG_ABOVE_CHIEF] }]);
|
||||
const host = run(base({ credentials: [{ service: "host" }] })).flagged;
|
||||
assert.deepEqual(host, [{ field: "credentials.host", value: "host", reasons: [FLAG_ABOVE_CHIEF] }]);
|
||||
const narrow = { ...CHIEF, messaging: { sendTo: ["pm"], receiveFrom: ["qa"] } };
|
||||
assert.deepEqual(run(base(), CORE, narrow).flagged, [
|
||||
{ field: "messaging.sendTo", value: "*", reasons: [FLAG_ABOVE_CHIEF] },
|
||||
{ field: "messaging.receiveFrom", value: "*", reasons: [FLAG_ABOVE_CHIEF] },
|
||||
]);
|
||||
assert.deepEqual(run(base({ messaging: { sendTo: ["pm", "cto"], receiveFrom: ["qa"] } }), CORE, narrow).flagged,
|
||||
[{ field: "messaging.sendTo", value: "cto", reasons: [FLAG_ABOVE_CHIEF] }]);
|
||||
// Review finding 1: a wider inbound list is above the chief too.
|
||||
assert.deepEqual(run(base({ messaging: { sendTo: ["pm"], receiveFrom: ["qa", "cto"] } }), CORE, narrow).flagged,
|
||||
[{ field: "messaging.receiveFrom", value: "cto", reasons: [FLAG_ABOVE_CHIEF] }]);
|
||||
// A chief with no messaging key holds both directions to everyone.
|
||||
assert.deepEqual(run(base({ messaging: { sendTo: ["cto"], receiveFrom: ["cto"] } })).flagged, []);
|
||||
});
|
||||
|
||||
// Review finding 2: the caller's object is read once, as JSON data, and nothing it throws escapes.
|
||||
test("a getter, proxy or toJSON that throws role text refuses with a fixed message", () => {
|
||||
const throwing = base();
|
||||
Object.defineProperty(throwing, "instructions", { enumerable: true, get() { throw new Error(SECRET); } });
|
||||
refuses(() => run(throwing), /^composition must be a JSON object$/);
|
||||
const proxy = new Proxy(base(), { ownKeys() { throw new Error(SECRET); } });
|
||||
refuses(() => run(proxy), /^composition must be a JSON object$/);
|
||||
const proto = new Proxy(base(), { getPrototypeOf() { throw new Error(SECRET); } });
|
||||
refuses(() => run(proto), /^composition must be a JSON object$/);
|
||||
refuses(() => run(base({ personality: { toJSON() { throw new Error(SECRET); } } })), /^composition must be a JSON object$/);
|
||||
const cyclic = base();
|
||||
cyclic.authority.self = cyclic;
|
||||
refuses(() => run(cyclic), /^composition must be a JSON object$/);
|
||||
refuses(() => run(base({ tools: ["read", 10n] })), /^composition must be a JSON object$/);
|
||||
const chief = { ...CHIEF };
|
||||
Object.defineProperty(chief, "tools", { enumerable: true, get() { throw new Error(SECRET); } });
|
||||
refuses(() => run(base(), CORE, chief), /chief's resolved role/);
|
||||
});
|
||||
|
||||
test("a getter that changes its answer is read once: what is checked is what comes back", () => {
|
||||
let reads = 0;
|
||||
const role = base();
|
||||
Object.defineProperty(role, "network", { enumerable: true, get() { reads += 1; return reads === 1 ? "none" : "open"; } });
|
||||
const { composition, flagged } = run(role);
|
||||
assert.equal(reads, 1);
|
||||
assert.equal(composition.network, "none");
|
||||
assert.deepEqual(flagged, []);
|
||||
});
|
||||
|
||||
// The plugin-declared set (section 7, registry shape only).
|
||||
test("a plugin action is accepted only with its prefix", () => {
|
||||
const registry = buildRegistry({ plugins: [{ name: "forge", tools: ["lathe"], actions: ["build.run"], services: [{ name: "store", scopes: ["read", "write"] }] }] });
|
||||
const ok = run(base({
|
||||
tools: ["read", "forge.lathe"],
|
||||
authority: { withinRole: ["forge.build.run"], crossRole: [] },
|
||||
credentials: [{ service: "forge.store", scopes: ["read"] }],
|
||||
}), registry);
|
||||
assert.deepEqual(ok.flagged.map((l) => `${l.field} ${l.value}`).sort(),
|
||||
["authority.withinRole forge.build.run", "credentials.forge.store read", "tools forge.lathe"]);
|
||||
refuses(() => run(base({ authority: { withinRole: ["build.run"], crossRole: [] } }), registry), /names no registered action/, "build.run");
|
||||
refuses(() => run(base({ tools: ["lathe"] }), registry), /names no registered tool/, "lathe");
|
||||
refuses(() => run(base({ credentials: [{ service: "store", scopes: ["read"] }] }), registry), /names no registered credential service/);
|
||||
refuses(() => run(base({ credentials: [{ service: "forge.store", scopes: ["admin"] }] }), registry), /is not a scope forge.store declares/, "admin");
|
||||
// Without the plugin in the registry, its names are unknown.
|
||||
refuses(() => run(base({ authority: { withinRole: ["forge.build.run"], crossRole: [] } })), /names no registered action/);
|
||||
});
|
||||
|
||||
test("a plugin refuses if it reuses a core name or declares a hard wait", () => {
|
||||
const reg = (plugin) => () => buildRegistry({ plugins: [plugin] });
|
||||
for (const action of GATED_ONLY) refuses(reg({ name: "forge", actions: [action] }), /a hard wait/);
|
||||
refuses(reg({ name: "forge", actions: ["message.send"] }), /reuses the core name "message.send"/);
|
||||
refuses(reg({ name: "forge", tools: ["read"] }), /reuses the core name "read"/);
|
||||
refuses(reg({ name: "forge", services: [{ name: "gitea", scopes: ["x"] }] }), /reuses the core name "gitea"/);
|
||||
for (const name of ["git", "role", "decision", "read", "gitea", "host", "open"]) {
|
||||
refuses(reg({ name, actions: ["x"] }), /is a core name/);
|
||||
}
|
||||
refuses(reg({ name: "Forge" }), /name must match/);
|
||||
refuses(reg({ name: "forge", extra: 1 }), /has a key outside/);
|
||||
refuses(reg({ name: "forge", actions: ["a", "a"] }), /repeats an earlier entry/);
|
||||
refuses(reg({ name: "forge", tools: ["a"], actions: ["a"] }), /declares "forge.a" twice/);
|
||||
refuses(reg({ name: "forge", actions: [`x ${SECRET}`] }), /must be a name matching/);
|
||||
refuses(reg({ name: "forge", services: [{ name: "store", scopes: [] }] }), /scopes must not be empty/);
|
||||
refuses(() => buildRegistry({ plugins: [{ name: "forge" }, { name: "forge" }] }), /plugin "forge" twice/);
|
||||
refuses(() => buildRegistry({ plugins: {} }), /plugins must be an array/);
|
||||
// A plugin action is routed, so it never reads as a hard wait at step 3.
|
||||
assert.ok(buildRegistry({ plugins: [{ name: "forge", actions: ["build.run"] }] }).routed.includes("forge.build.run"));
|
||||
});
|
||||
|
||||
test("HIRE_ALLOWED and unhireableAction stay until A4", () => {
|
||||
assert.ok(Array.isArray(HIRE_ALLOWED) && HIRE_ALLOWED.length > 0);
|
||||
assert.equal(typeof unhireableAction, "function");
|
||||
});
|
||||
@@ -1,130 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import { syncBuiltinESMExports } from 'node:module';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmp, writeJson, REPO_ROLES } from './helpers.mjs';
|
||||
import { loadBusiness } from '../src/organization-config.mjs';
|
||||
import { loadBusiness as legacyLoadBusiness } from '../../business/src/business.mjs';
|
||||
import { loadProject } from '../src/project.mjs';
|
||||
import { loadRootAgents } from '../src/agent-levels.mjs';
|
||||
import { readJsonFile } from '../src/util.mjs';
|
||||
import { organizationWriter } from '../src/organization.mjs';
|
||||
import { readRegistry, withRegistryWriter } from '../src/org-registry.mjs';
|
||||
import { createOrg, deleteOrg, notificationShow, showOrg, templateRender } from '../src/org-service.mjs';
|
||||
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
const malformed = secret;
|
||||
function fixture(t) {
|
||||
const root = tmp();
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const ctx = { configDir: join(root, 'config'), dataRoot: join(root, 'data'), instance: 'test',
|
||||
principal: { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' } };
|
||||
for (const dir of [ctx.configDir, ctx.dataRoot]) mkdirSync(dir, { mode: 0o700 });
|
||||
return ctx;
|
||||
}
|
||||
function safe(file, { name = 'SyntaxError', code, exitCode, prefix } = {}) {
|
||||
return error => {
|
||||
assert.equal(error.name, name);
|
||||
assert.equal(error.code, code);
|
||||
assert.equal(error.exitCode, exitCode);
|
||||
assert.ok(error.message.includes(file), error.message);
|
||||
assert.match(error.message, /not valid JSON/);
|
||||
assert.doesNotMatch(error.message, /sk-test|private|broken|Unexpected|position|line \d/);
|
||||
assert.ok(!error.stack.includes(secret));
|
||||
assert.equal(error.cause, undefined);
|
||||
if (prefix) assert.ok(error.message.startsWith(prefix));
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
for (const [label, load, relative] of [
|
||||
['shared reader', file => readJsonFile(file, 'config file'), 'config.json'],
|
||||
['organization loader', (_file, ctx) => loadBusiness('alpha', { dir: ctx.configDir, rolesDir: REPO_ROLES }), 'businesses/alpha.json'],
|
||||
['legacy business loader', (_file, ctx) => legacyLoadBusiness('alpha', { dir: ctx.configDir, rolesDir: REPO_ROLES }), 'businesses/alpha.json'],
|
||||
['project loader', (_file, ctx) => loadProject(ctx.configDir), '.mosaic/project.json'],
|
||||
]) test(`${label} names malformed JSON's file without quoting bytes`, t => {
|
||||
const ctx = fixture(t), file = writeJson(join(ctx.configDir, relative), malformed);
|
||||
assert.throws(() => load(file, ctx), safe(file, { name: 'BusinessError', exitCode: 2 }));
|
||||
});
|
||||
|
||||
test('root agents keep their existing file-only refusal', t => {
|
||||
const ctx = fixture(t); writeJson(join(ctx.configDir, 'agents.json'), malformed);
|
||||
assert.throws(() => loadRootAgents(ctx.configDir, { rolesDir: REPO_ROLES }), safe('agents.json', { name: 'BusinessError', exitCode: 2 }));
|
||||
});
|
||||
|
||||
for (const operation of ['read', 'write', 'recover', 'findAddition']) test(`organization writer ${operation} does not quote malformed file bytes`, async t => {
|
||||
const ctx = fixture(t);
|
||||
await createOrg(ctx, 'alpha', await templateRender('alpha'));
|
||||
const writer = organizationWriter({ dir: ctx.configDir, rolesDir: REPO_ROLES });
|
||||
const current = writer.read('alpha');
|
||||
const receipt = ['recover', 'findAddition'].includes(operation);
|
||||
const file = receipt ? writer.logFile('alpha') : join(ctx.configDir, 'businesses/alpha.json');
|
||||
if (receipt) mkdirSync(join(ctx.configDir, 'businesses/.history/alpha'), { recursive: true, mode: 0o700 });
|
||||
writeJson(file, malformed + '\n');
|
||||
if (operation === 'write') {
|
||||
// Exercise the parse after both the revision and registry digest fences.
|
||||
current.revision = createHash('sha256').update(readFileSync(file)).digest('hex');
|
||||
const registryFile = join(ctx.configDir, 'org-runtime.json');
|
||||
const registry = JSON.parse(readFileSync(registryFile)); registry.orgs.alpha.digest = current.revision;
|
||||
writeJson(registryFile, registry);
|
||||
}
|
||||
const invoke = () => operation === 'write'
|
||||
? writer.write('alpha', current.document, { revision: current.revision, actor: ctx.principal.sub })
|
||||
: writer[operation]('alpha', {});
|
||||
assert.throws(invoke, safe(file));
|
||||
});
|
||||
|
||||
for (const operation of ['async', 'sync']) test(`${operation} registry read names the file without parser bytes`, async t => {
|
||||
const ctx = fixture(t), file = writeJson(join(ctx.configDir, 'org-runtime.json'), malformed);
|
||||
if (operation === 'async') await assert.rejects(readRegistry(ctx), safe(file, { name: 'OrgError', code: 'org-mismatch', prefix: 'invalid registry: ' }));
|
||||
else assert.throws(() => withRegistryWriter(ctx.configDir, () => assert.fail('must refuse')), safe(file));
|
||||
});
|
||||
|
||||
for (const kind of ['owner', 'organization', 'notifier', 'deletion receipt']) test(`service ${kind} refuses malformed JSON without file bytes`, async t => {
|
||||
const ctx = fixture(t);
|
||||
const created = await createOrg(ctx, 'alpha', await templateRender('alpha'));
|
||||
let file;
|
||||
if (kind === 'owner') file = join(created.root, 'owner.json');
|
||||
if (kind === 'organization') file = join(ctx.configDir, 'businesses/alpha.json');
|
||||
if (kind === 'notifier') file = join(created.root, 'notify/alpha/notify.json');
|
||||
if (kind === 'deletion receipt') {
|
||||
const registryFile = join(ctx.configDir, 'org-runtime.json');
|
||||
const registry = JSON.parse(readFileSync(registryFile));
|
||||
registry.orgs.alpha.state = 'deleting';
|
||||
registry.orgs.alpha.deletion = { actor: ctx.principal, time: new Date().toISOString(), revision: 1, folder: 'alpha-redaction' };
|
||||
writeJson(registryFile, registry);
|
||||
const destination = join(ctx.dataRoot, 'deleted/alpha-redaction');
|
||||
mkdirSync(destination, { recursive: true, mode: 0o700 });
|
||||
file = join(destination, 'receipt.json');
|
||||
}
|
||||
writeJson(file, malformed);
|
||||
if (kind === 'organization') {
|
||||
const registryFile = join(ctx.configDir, 'org-runtime.json');
|
||||
const registry = JSON.parse(readFileSync(registryFile));
|
||||
registry.orgs.alpha.digest = createHash('sha256').update(malformed).digest('hex');
|
||||
writeJson(registryFile, registry);
|
||||
}
|
||||
if (kind === 'notifier') await assert.rejects(notificationShow(ctx, 'alpha'), safe(file));
|
||||
else if (kind === 'deletion receipt') await assert.rejects(deleteOrg(ctx, 'alpha', { confirm: 'alpha', ifRevision: 1, busStop: async () => assert.fail('must refuse first') }), safe(file));
|
||||
else await assert.rejects(showOrg(ctx, 'alpha'), safe(file));
|
||||
});
|
||||
|
||||
// The packaged template is immutable at runtime; substitute only its read result
|
||||
// to exercise a corrupt template without editing repository data.
|
||||
test('template reader refuses malformed JSON without quoting template bytes', async t => {
|
||||
const original = fsPromises.readFile;
|
||||
t.mock.method(fsPromises, 'readFile', function(file, ...args) {
|
||||
if (file instanceof URL && file.pathname.endsWith('/org-template.json')) return Promise.resolve(malformed);
|
||||
return original.call(this, file, ...args);
|
||||
});
|
||||
syncBuiltinESMExports();
|
||||
try {
|
||||
await assert.rejects(templateRender('alpha'), safe('org-template.json'));
|
||||
} finally {
|
||||
t.mock.restoreAll();
|
||||
syncBuiltinESMExports();
|
||||
}
|
||||
});
|
||||
@@ -12,14 +12,13 @@ const RIGHTS = {
|
||||
'board.read': { grantable: true },
|
||||
'task.write': { grantable: true },
|
||||
decide: { grantable: true },
|
||||
'org.hire': { grantable: true },
|
||||
'request.write': { grantable: false },
|
||||
'members.manage': { grantable: false },
|
||||
'org.setup': { grantable: false },
|
||||
'org.lifecycle': { grantable: false },
|
||||
};
|
||||
const BUNDLES = {
|
||||
owner: ['board.read', 'decide', 'members.manage', 'org.hire', 'org.lifecycle', 'org.setup', 'org.view', 'request.write', 'task.write', 'thread.read', 'thread.write'],
|
||||
owner: ['board.read', 'decide', 'members.manage', 'org.lifecycle', 'org.setup', 'org.view', 'request.write', 'task.write', 'thread.read', 'thread.write'],
|
||||
member: ['board.read', 'org.view', 'thread.read'],
|
||||
collaborator: ['board.read', 'org.view', 'task.write', 'thread.read', 'thread.write'],
|
||||
approver: ['board.read', 'decide', 'org.view', 'task.write', 'thread.read', 'thread.write'],
|
||||
@@ -59,7 +58,6 @@ const VERBS = {
|
||||
'tasks.delete': null,
|
||||
'decision.preview.console': 'decide',
|
||||
'decision.resolve.console': 'decide',
|
||||
'hire.resolve.console': 'org.hire',
|
||||
'request.answer.console': 'request.write',
|
||||
'request.attach.console': 'request.write',
|
||||
'request.cancel.console': 'request.write',
|
||||
@@ -72,32 +70,10 @@ const VERBS = {
|
||||
'project.remote.set.console': 'org.setup',
|
||||
};
|
||||
|
||||
test('M1/R1 the eleven rights pin the grantable flags, including compulsory org.view', () => {
|
||||
test('M1 the ten rights pin the grantable flags, including compulsory org.view', () => {
|
||||
assert.deepEqual({ ...policy.RIGHTS }, RIGHTS);
|
||||
});
|
||||
|
||||
test('R1 org.hire belongs to owner and no non-owner template', () => {
|
||||
assert.ok(policy.ROLE_RIGHTS.owner.includes('org.hire'));
|
||||
for (const role of ['member', 'collaborator', 'approver']) {
|
||||
assert.ok(!policy.ROLE_RIGHTS[role].includes('org.hire'), role);
|
||||
}
|
||||
});
|
||||
|
||||
test('R1 an owner can grant org.hire to a member without widening the template', () => {
|
||||
const rights = policy.normalizeRights(policy.ROLE_RIGHTS.member, { add: ['org.hire'] });
|
||||
assert.deepEqual(rights, ['board.read', 'org.hire', 'org.view', 'thread.read']);
|
||||
assert.equal(policy.canGrant(policy.ROLE_RIGHTS.owner, rights), true);
|
||||
assert.equal(policy.canGrant(policy.ROLE_RIGHTS.member, ['org.hire']), false);
|
||||
assert.equal(policy.canGrant(policy.ROLE_RIGHTS.owner, ['org.hire', 'future.right']), false);
|
||||
assert.throws(() => policy.normalizeRights(rights, { add: ['future.right'] }), { code: 'unknown-right' });
|
||||
assert.deepEqual(policy.ROLE_RIGHTS.member, ['board.read', 'org.view', 'thread.read']);
|
||||
});
|
||||
|
||||
test('R1 hire resolution requires org.hire while generic decision resolution keeps decide', () => {
|
||||
assert.equal(policy.VERB_RIGHTS['hire.resolve.console'], 'org.hire');
|
||||
assert.equal(policy.VERB_RIGHTS['decision.resolve.console'], 'decide');
|
||||
});
|
||||
|
||||
for (const [role, rights] of Object.entries(BUNDLES)) {
|
||||
test(`M1 ${role} has exactly its reviewed rights, expanded and sorted`, () => {
|
||||
assert.deepEqual(policy.ROLE_RIGHTS[role], rights);
|
||||
@@ -185,7 +161,6 @@ test('M1 every actual console, user, board and person-task verb has a reviewed a
|
||||
}
|
||||
assert.deepEqual(Object.keys(VERBS).sort(), [...new Set([
|
||||
...CONSOLE_VERBS, ...USER_VERBS, ...BOARD_VERBS, ...PERSON_TASK_VERBS, 'plugins', 'plugins.set', 'limits',
|
||||
'hire.resolve.console', // R1 reserves the policy mapping; A4 implements the verb.
|
||||
])].sort());
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ 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';
|
||||
@@ -170,9 +169,6 @@ 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 }),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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><div data-chat-system-types>System types 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>`;
|
||||
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,9 +684,6 @@
|
||||
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');
|
||||
|
||||
@@ -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><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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
/* 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}
|
||||
@@ -1,195 +0,0 @@
|
||||
// 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 });
|
||||
})();
|
||||
@@ -1,8 +1,7 @@
|
||||
// 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, and the
|
||||
// System types chips, row and section are system-types.js's (Phase P2), with its own reads and writes.
|
||||
// restart button is console-restart.js's own control, with its gating and trail unchanged.
|
||||
(() => {
|
||||
'use strict';
|
||||
if (!document.documentElement.hasAttribute('data-organizations')) return;
|
||||
@@ -155,7 +154,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, types } = ctx;
|
||||
const { business, settings, instances, orgCount } = ctx;
|
||||
const b = business?.business, s = settings;
|
||||
const unread = 'Could not be read';
|
||||
const names = instances ? Object.keys(instances) : null;
|
||||
@@ -167,7 +166,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', types ? types.summary(org.id) : 'Could not be read', 'org file', types ? '@system-types' : null],
|
||||
['cube', 'System types', 'Not built yet', 'not built', 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'],
|
||||
@@ -186,10 +185,8 @@
|
||||
['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 || 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>';
|
||||
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>';
|
||||
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>`;
|
||||
}
|
||||
@@ -203,8 +200,7 @@
|
||||
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 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 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 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));
|
||||
@@ -224,7 +220,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 = 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 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 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>`;
|
||||
|
||||
@@ -262,8 +258,7 @@
|
||||
</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, types })}
|
||||
${types ? types.section(id, { pane, idPrefix: opts.idPrefix || '' }) : ''}
|
||||
${settingsList(org, { business, settings, instances, orgCount: list ? list.length : null })}
|
||||
</section>
|
||||
</div>
|
||||
</article>`;
|
||||
|
||||
@@ -18,7 +18,6 @@ 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';
|
||||
@@ -77,9 +76,6 @@ 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']]),
|
||||
]);
|
||||
@@ -511,9 +507,7 @@ 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/')
|
||||
// Phase P2 (#1873): the System types enable switch and a project's selection; the route keeps its own checks.
|
||||
|| SYSTEM_TYPES_ROUTE.test(path);
|
||||
|| Object.hasOwn(SEAT_ROUTES, path) || Object.hasOwn(AGENT_ROUTES, path) || path.startsWith('/api/proxy/');
|
||||
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.
|
||||
@@ -779,7 +773,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, 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 } = {}) {
|
||||
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 } = {}) {
|
||||
// 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.
|
||||
@@ -802,8 +796,6 @@ 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);
|
||||
@@ -826,7 +818,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, systemTypesWrite, upstream, files };
|
||||
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 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 }))) {
|
||||
@@ -840,7 +832,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, systemTypesWrite, 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, 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
|
||||
@@ -910,7 +902,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), systemTypesPath = SYSTEM_TYPES_ROUTE.test(path);
|
||||
const decisionPath = DECISION_ROUTE.test(path), requestPath = REQUEST_ROUTE.test(path), remotePath = PROJECT_REMOTE_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;
|
||||
@@ -922,7 +914,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 || systemTypesPath) return json(res, 401, { error: 'sign-in-required' });
|
||||
if (remotePath) 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();
|
||||
@@ -936,7 +928,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; systemTypesWrite = null;
|
||||
reads = null; upstream = null; libraryStatus = null; libraryRevert = null; files = USER_FILES; granted = true; proxyCall = null;
|
||||
chatGrants = sso.chatGrants?.(session) ?? [];
|
||||
}
|
||||
}
|
||||
@@ -967,7 +959,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, systemTypesWrite = null, upstream = null } = selected);
|
||||
processing = null, decisionWrite = null, requestRead = null, requestWrite = 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.
|
||||
@@ -1024,13 +1016,7 @@ 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 });
|
||||
}
|
||||
// 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' });
|
||||
if (tester && ['/project-remote.js', '/project-remote.css', '/files.js', '/files.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 });
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
// 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);
|
||||
}
|
||||
@@ -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. 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']);
|
||||
// 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']);
|
||||
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);
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
// 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, []);
|
||||
});
|
||||
@@ -1,235 +0,0 @@
|
||||
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,13 +6,11 @@ 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. 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 = {} } = {}) {
|
||||
// Three organizations as sample 01 and 03 show them: two running buses and one stopped.
|
||||
function workspaceOrgs({ failThreads = false, seen = null } = {}) {
|
||||
const ORGS = [
|
||||
{ id: 'ddigital', label: 'Distinctly Digital', theme: 'rosewood', busHealthy: true },
|
||||
{ id: 'forge', label: 'Forge', theme: 'cobalt', busHealthy: true },
|
||||
@@ -26,8 +24,6 @@ function workspaceOrgs({ failThreads = false, seen = null, send = false, types =
|
||||
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: [] }])) });
|
||||
@@ -39,10 +35,8 @@ function workspaceOrgs({ failThreads = false, seen = null, send = false, types =
|
||||
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, requestRead: st.requestRead, systemTypesWrite: send ? st.systemTypesWrite : null, ...(send ? { send: async () => ({}) } : {}),
|
||||
busHealthy: true,
|
||||
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 () => [] },
|
||||
@@ -131,8 +125,7 @@ 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);
|
||||
// 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);
|
||||
assert.match(await b.evaluate('document.querySelector(".ws-typechips").innerText'), /System types\s+No system types yet/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.
|
||||
@@ -157,8 +150,7 @@ 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'].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['System types'].tag, by['System types'].href], ['not built', null]);
|
||||
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');
|
||||
@@ -178,8 +170,7 @@ 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.
|
||||
// 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/])
|
||||
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/])
|
||||
assert.match(text, line);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("[data-org-settings] > li").length'), SETTINGS.length);
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@ 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 });
|
||||
|
||||
@@ -133,7 +133,7 @@ function readMarker(dataRoot) {
|
||||
try {
|
||||
marker = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
} catch (error) {
|
||||
fail(3, `instance-root-unclaimed: ${file} is not a valid marker (${error instanceof SyntaxError ? 'not valid JSON' : error.message})`);
|
||||
fail(3, `instance-root-unclaimed: ${file} is not a valid marker (${error.message})`);
|
||||
}
|
||||
if (!isPlainObject(marker) || Object.keys(marker).sort().join(",") !== "instanceVersion,name"
|
||||
|| marker.instanceVersion !== 1 || typeof marker.name !== "string" || !INSTANCE_PATTERN.test(marker.name)) {
|
||||
@@ -382,8 +382,8 @@ async function load(file, instance = null) {
|
||||
let document;
|
||||
try {
|
||||
document = JSON.parse(raw);
|
||||
} catch {
|
||||
fail(2, `configuration is not valid JSON (${file})`);
|
||||
} catch (error) {
|
||||
fail(2, `configuration is not valid JSON (${file}): ${error.message}`);
|
||||
}
|
||||
return await validate(document, file);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user