Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82dcba2d45 | ||
|
|
29f7c3af85 | ||
|
|
17a48217f6 | ||
|
|
9d00f9ad54 | ||
|
|
a3e82efe23 | ||
|
|
c8ff8ea128 | ||
|
|
ddf4f895cd | ||
|
|
7035ce61b3 | ||
|
|
54bf239e32 | ||
|
|
a4eed1e219 | ||
|
|
1e1bbaa6dd | ||
|
|
78bda9d6a4 | ||
|
|
6b39af7888 | ||
|
|
96c900d210 | ||
|
|
307fe928ac | ||
|
|
e9a079fcc4 | ||
|
|
7de9cfccc3 | ||
|
|
c3c192677d | ||
|
|
95ee19cc71 | ||
|
|
07dba7f2f5 | ||
|
|
ceee80d5b7 | ||
|
|
fa9bedb09a | ||
|
|
67d6c9ed84 | ||
|
|
c8dbd3f6ec | ||
|
|
8f7de8c4d3 | ||
|
|
6f0c72a02f | ||
|
|
257a156845 | ||
|
|
b6e52e1b52 | ||
|
|
3bcdee0926 | ||
|
|
9f0fcf8690 | ||
|
|
c7b588289f | ||
|
|
aa9f936690 | ||
|
|
49a8d2983e | ||
|
|
d630f815c2 | ||
|
|
14f1b3b2b1 | ||
|
|
f98f278fbb | ||
|
|
c6e341e92e | ||
|
|
50be333552 | ||
|
|
3bb0f70acc |
No files matched your search
@@ -0,0 +1,671 @@
|
||||
# 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.
|
||||
@@ -189,8 +189,15 @@ those messages stranded: no later holder receives or reads them, and `agents`
|
||||
lists them under `stranded` (Agent state, below). `message.read` is an explicit acknowledgement. `trail(messageId)` lets
|
||||
an operator inspect content and delivery evidence after an uncertain result.
|
||||
|
||||
Broker socket refusals retain `ok:false`, `error` and any scoped `reason`, and add
|
||||
`rail` and `nextStep` from `src/refusals.mjs`. Guidance is static and never
|
||||
quotes caller input or file bytes. The runner and typed tools carry the guidance
|
||||
to the agent. `src/rails.mjs` in the organization package supplies the shared
|
||||
prompt/live projection; `decisionAutonomy` is the extension point for AU.
|
||||
|
||||
| Verb | Arguments and result |
|
||||
|---|---|
|
||||
| `my_rails` | No args. Read only the current caller's held actions and routes, resolve rights with fixed autonomy levels, network, credential service/scope names and messaging targets. Recomputed from live policy on every call; never returns credential references or values, writes evidence, or raises a decision. Exposed to every managed role as `my_rails`. |
|
||||
| `role.claim` | No args; claims capability's role/run. One current holder per business/instance; a revoked run cannot reclaim that role, including after restart. |
|
||||
| `role.release` | No args; only its current holder releases. |
|
||||
| `role.revoke` | `{role,decision}`; needs an approved decision for this exact target and holder incarnation, routed as REQ-DEC-2 says: the human's for an uncited gated raise, the arbiter's for one citing the current spec. A role whose file lists `role.end` (the chief of staff, row 123, REQ-COS-1) sends `{role,reason}` instead: no decision, a reason of 1 to 500 characters, logged in the `action.allowed` body. Sending both is `invalid-request`; the caller's own run is `role-end-refused`; any other role sending `reason` gets `decision-required`. An instance whose definition is in `END_PROTECTED` (the assistant) is ended or revoked only by the chief of staff or with a decision the human resolved through the CLI (`via` `cli`); anyone else's reason, or a decision an arbiter resolved, is `role-end-refused` (Jason's ruling Q169, row 139). A target whose definition is `THREAD_DEFINITION` (the assistant) has no role claim: the end revokes the current claim of each of its threads, refuses `not-held` when none is held, and returns `{revoked:true,threads:N}` (row 155, #1649). |
|
||||
|
||||
+109
-6
@@ -1,3 +1,5 @@
|
||||
import { ownRails } from '../../organization/src/rails.mjs';
|
||||
import { refusalGuidance } from './refusals.mjs';
|
||||
import { PROXY_PAGE_VERBS, PROXY_PAGE_CODES } from './proxy-page-vocabulary.mjs';
|
||||
import { randomUUID, randomBytes, createHash } from 'node:crypto';
|
||||
import { validManagementKey, proxyTiming } from '../../model-proxy/src/validation.mjs';
|
||||
@@ -50,6 +52,7 @@ export const ACTIONS = Object.freeze([
|
||||
'policy.change',
|
||||
'prd.approve',
|
||||
'decision.resolve.technical',
|
||||
'decision.resolve.delivery',
|
||||
// Row 285 (C2-08b, #1790): routed under PRD 1.16 REQ-WAIT-3 and REQ-LIB-6, below.
|
||||
'library.write',
|
||||
]);
|
||||
@@ -78,6 +81,7 @@ const ROUTED = new Set([
|
||||
'policy.change',
|
||||
'prd.approve',
|
||||
'decision.resolve.technical',
|
||||
'decision.resolve.delivery',
|
||||
// PRD 1.7 REQ-COS-3 (row 194, #1692): the chief's hire and fire, within the bounds the broker checks.
|
||||
'role.hire',
|
||||
'role.fire',
|
||||
@@ -88,6 +92,8 @@ const ROUTED = new Set([
|
||||
export const HARD_WAITS = Object.freeze(ACTIONS.filter((action) => !ROUTED.has(action)));
|
||||
const GATED = new Set(HARD_WAITS);
|
||||
const CLOSED = "('resolved','withdrawn','expired')";
|
||||
// RR (#1873): these actions never take their domain from the raising agent.
|
||||
const DECISION_DOMAINS = Object.freeze({ 'git.merge.protected': 'technical', 'policy.change': 'delivery' });
|
||||
// How many closed decisions `decisions` with list 'recent' returns, newest close first.
|
||||
export const RECENT = 50;
|
||||
// How many replies `replies` returns, oldest first.
|
||||
@@ -241,10 +247,11 @@ const canonical = (x) =>
|
||||
Number.isFinite(Date.parse(x)) &&
|
||||
new Date(x).toISOString() === x;
|
||||
export class BusError extends Error {
|
||||
// Only explicitly scoped refusals carry a reason; the envelope passes it on.
|
||||
// Guidance is static policy text; never quote arguments or file bytes here.
|
||||
constructor(code, reason) {
|
||||
super(code);
|
||||
this.code = code;
|
||||
Object.assign(this, refusalGuidance(code));
|
||||
if (reason !== undefined) this.reason = reason;
|
||||
}
|
||||
}
|
||||
@@ -1213,6 +1220,11 @@ export class Broker {
|
||||
this.#businesses[name] = next;
|
||||
this.#scheduleRequests();
|
||||
}
|
||||
// #1873 A3: host-only, for the launch boundary's admission: the code that refuses `instance` in code
|
||||
// project `repo` under the business's current document (the one the last reload swapped in), or null.
|
||||
projectBarred(business, instance, repo) {
|
||||
return this.#projectRefused(this.#business(business), instance, repo)?.code ?? null;
|
||||
}
|
||||
// Host-only current role resolver shared by the broker and built-in task tracker.
|
||||
taskRoles(business) {
|
||||
const roles = this.#business(business).roles;
|
||||
@@ -1527,11 +1539,40 @@ export class Broker {
|
||||
this.#processingWork();
|
||||
const b = this.#business(s.business);
|
||||
if ((b.retired ?? []).includes(instance)) fail('instance-retired');
|
||||
this.#copyRefused(b, instance);
|
||||
if (!Object.hasOwn(b.roles, instance)) fail('unknown-role');
|
||||
if (this.#threaded(s.business, instance)) fail(thread);
|
||||
if (this.#store.get('SELECT state FROM launch_state WHERE business=?', s.business)?.state === 'revoked') fail('launch-revoked');
|
||||
if (this.#firing(s.business, instance)) fail('instance-firing');
|
||||
}
|
||||
// #1873 A3: a root or project copy the boot left unbound or refused isn't in `roles`. Its refusal
|
||||
// names the agent and the missing services or the reason, never a value (organization
|
||||
// agent-copies.mjs copyRefusal; inlined because this module also loads in the sandbox client).
|
||||
#copyRefused(b, instance) {
|
||||
const copy = b.copies && Object.hasOwn(b.copies, instance) ? b.copies[instance] : null;
|
||||
if (!copy || copy.state === 'instance') return;
|
||||
if (copy.state === 'unbound') throw new BusError('credential-unbound', `${instance} has no binding in ${b.id} for ${copy.missing.join(', ')}`);
|
||||
throw new BusError(copy.project === null ? 'copy-refused' : 'project-refused', `${instance} can't launch in ${b.id}: ${copy.reason}`);
|
||||
}
|
||||
// #1873 A3: why `instance` can't work in code project `repo`, as a BusError, or null when it can
|
||||
// (organization agent-copies.mjs projectRefusal, inlined for the same reason). A project copy works
|
||||
// only in its own project; in a project whose union refuses no agent works; an instance the
|
||||
// project excludes doesn't work there. The reason names the instance, the project and why.
|
||||
#projectRefused(b, instance, repo) {
|
||||
const copy = b.copies && Object.hasOwn(b.copies, instance) ? b.copies[instance] : null;
|
||||
if (copy && copy.project !== null && copy.project !== repo)
|
||||
return new BusError('project-agent-elsewhere', `${instance} works only in project ${copy.project}`);
|
||||
const union = repo === null || !b.unions || !Object.hasOwn(b.unions, repo) ? null : b.unions[repo];
|
||||
if (!union) return null;
|
||||
if (!union.ok) return new BusError('project-refused', `${instance} can't work in project ${repo}: project ${repo} refuses its agents: ${union.reason}`);
|
||||
if (!union.agents.includes(instance)) return new BusError('project-refused', `${instance} can't work in project ${repo}: the project excludes it`);
|
||||
return null;
|
||||
}
|
||||
// #1873 A3: the union refusal of code project `repo`, as a BusError naming the project and why, or null.
|
||||
#unionRefused(b, repo) {
|
||||
const union = repo === null || !b.unions || !Object.hasOwn(b.unions, repo) ? null : b.unions[repo];
|
||||
return union && !union.ok ? new BusError('project-refused', `project ${repo} refuses its agents: ${union.reason}`) : null;
|
||||
}
|
||||
// W-N1 (webby-0014): a fire of I stopped its runs, and no fire of I has finished since.
|
||||
#firing(business, instance) {
|
||||
return !!this.#store.get(
|
||||
@@ -1845,7 +1886,15 @@ export class Broker {
|
||||
this.#revokeLost('access-expiry', true, doc);
|
||||
rows = this.#threadRows('t.business=?', business);
|
||||
}
|
||||
return rows.map(({ owner, project, ...r }) => ({ ...r, ...(this.#archived(business, project) ? { archived: true } : {}), ...(lost(r, owner, project) ? { revoked: true } : {}) }));
|
||||
// #1873 A3: a thread whose instance no longer works in its project's code project (the project's
|
||||
// union refuses, or it excludes the instance) says so, so the launcher refuses its launch.
|
||||
const b = this.#business(business);
|
||||
const refusedIn = (instance, project) => {
|
||||
if (!b.unions && !b.copies) return null;
|
||||
const e = this.#projectRefused(b, instance, this.#store.get('SELECT repo FROM projects WHERE id=? AND business=?', project, business)?.repo ?? null);
|
||||
return e ? { projectRefused: { code: e.code, reason: e.reason } } : null;
|
||||
};
|
||||
return rows.map(({ owner, project, ...r }) => ({ ...r, ...(this.#archived(business, project) ? { archived: true } : {}), ...(lost(r, owner, project) ? { revoked: true } : {}), ...refusedIn(r.instance, project) }));
|
||||
}
|
||||
// Row 121 round 2 (#1615, Dewey's F3 in 28591): the launcher's idle stop. The tick decides
|
||||
// from a threadsState snapshot, and a message the run took after it would be delivered to a
|
||||
@@ -5061,6 +5110,20 @@ export class Broker {
|
||||
);
|
||||
return evidence ? JSON.parse(evidence.body) : null;
|
||||
}
|
||||
#checkResolver(s, d) {
|
||||
const b = this.#business(s.business), role = b.roles[s.role];
|
||||
const domain = d.action === 'prd.approve' ? 'technical' : DECISION_DOMAINS[d.action] ?? this.#raised(s.business, d.id)?.domain;
|
||||
// Pre-RR evidence has no domain. Assignments may have changed since raise,
|
||||
// so require both possible rights rather than infer one from today's file.
|
||||
const domains = domain === undefined ? ['technical', 'delivery'] : [domain];
|
||||
for (const kind of domains) {
|
||||
if (!['technical', 'delivery'].includes(kind)) fail('not-resolver');
|
||||
const right = `decision.resolve.${kind}`;
|
||||
if (!role?.authority?.withinRole.includes(right)) {
|
||||
throw new BusError('not-resolver', `instance ${s.role}, role ${role?.definition ?? s.role}, lacks ${right}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Whether an executor already used the decision. decision.raise records context, not permission
|
||||
// consumed by an executor.
|
||||
#spent(business, decision) {
|
||||
@@ -6089,6 +6152,12 @@ export class Broker {
|
||||
entry.running = this.#running(s.business, name);
|
||||
entry.alive = this.#aliveAfterFire(s.business, name);
|
||||
}
|
||||
// #1873 A3: the copies this bus booted with, and each one's running runs. The file's own
|
||||
// instances above carry no copy; a copy carries its state, missing service names or reason.
|
||||
if (request.verb === "instances" && result?.instances && typeof result.instances === "object" && this.#business(s.business).copies)
|
||||
result.copies = Object.fromEntries(Object.entries(this.#business(s.business).copies).map(([name, c]) => [name, {
|
||||
...structuredClone(c), running: c.state === 'instance' ? this.#running(s.business, name) : [],
|
||||
}]));
|
||||
return result;
|
||||
}
|
||||
return this.#store.transaction(() => {
|
||||
@@ -6920,6 +6989,13 @@ export class Broker {
|
||||
// Preserve the common request gates and refuse additions until the vocabulary names them.
|
||||
if (verb.startsWith('tasks.') && !PERSON_TASK_VERBS.includes(verb)) fail('unknown-verb');
|
||||
switch (verb) {
|
||||
case 'my_rails': {
|
||||
keys(a, []);
|
||||
this.#agent(s);
|
||||
const entry = b.roles[s.role], definition = b.definitions?.[entry.definition];
|
||||
return ownRails({ instance: s.role, authority: entry.authority, network: entry.network ?? definition?.network,
|
||||
credentials: definition?.credentials ?? [], messaging: definition?.messaging, arbiters: b.arbiters, launcher: b.launch?.by });
|
||||
}
|
||||
case 'role.claim': {
|
||||
keys(a, []);
|
||||
if (s.human) fail('agent-required');
|
||||
@@ -7064,8 +7140,8 @@ export class Broker {
|
||||
if (!choices.includes(a.recommendation)) fail('invalid-recommendation');
|
||||
const approvalChoice = a.approvalChoice ?? 'yes';
|
||||
if (!choices.includes(approvalChoice)) fail('invalid-approval-choice');
|
||||
const domain = a.domain ?? 'delivery';
|
||||
if (!['technical', 'delivery'].includes(domain)) fail('invalid-domain');
|
||||
if (a.domain !== undefined && !['technical', 'delivery'].includes(a.domain)) fail('invalid-domain');
|
||||
const domain = DECISION_DOMAINS[a.action] ?? a.domain ?? 'delivery';
|
||||
// PRD 1.5 REQ-DEC-2 (row 139, #1633): a gated raise citing the project's current spec commit
|
||||
// goes to the arbiter for its domain, like a cross-role one. An uncited gated raise and any
|
||||
// hard wait go to the human. readSpec already refused a stale or unconfirmed citation.
|
||||
@@ -7147,6 +7223,7 @@ export class Broker {
|
||||
decision: did,
|
||||
action: a.action,
|
||||
operation: 'decision.raise',
|
||||
domain,
|
||||
task_ref: a.task_ref ?? null,
|
||||
target: a.target ?? null,
|
||||
approvalChoice,
|
||||
@@ -7213,6 +7290,7 @@ export class Broker {
|
||||
else {
|
||||
this.#agent(s);
|
||||
if (s.role !== d.route_to) fail('not-resolver');
|
||||
if (verb === 'decision.resolve') this.#checkResolver(s, d);
|
||||
}
|
||||
const op = verb === 'decision.resolve' || viaConsole ? 'resolved' : verb === 'decision.seen' ? 'seen' : 'withdrawn';
|
||||
if (op === 'resolved' && !JSON.parse(d.options).some((o) => o.key === a.choice))
|
||||
@@ -8003,7 +8081,17 @@ export class Broker {
|
||||
// #797 MVP3: an archived project takes no new room threads and no new requests; reading it
|
||||
// (room.read) and every existing record stay open.
|
||||
if ((opening || sending) && this.#archived(s.business, project)) fail('project-archived');
|
||||
const members = Object.keys(b.roles).filter((instance) => this.#threaded(s.business, instance)).sort();
|
||||
// #1873 A3: the room's members are the thread instances that work in its code project. A project
|
||||
// whose union refuses takes no new room thread or request, naming the project and why; its
|
||||
// existing threads stay readable.
|
||||
const repo = this.#store.get('SELECT repo FROM projects WHERE id=? AND business=?', project, s.business)?.repo ?? null;
|
||||
const unionRefused = this.#unionRefused(b, repo);
|
||||
if ((opening || sending) && unionRefused) throw unionRefused;
|
||||
const members = Object.keys(b.roles).filter((instance) => {
|
||||
if (!this.#threaded(s.business, instance)) return false;
|
||||
const refused = this.#projectRefused(b, instance, repo);
|
||||
return !refused || (unionRefused !== null && refused.code === 'project-refused');
|
||||
}).sort();
|
||||
if (!members.length) fail('thread-instance-missing');
|
||||
const owner = this.#owner(s);
|
||||
// Preflight all collisions/missing threads before open creates or send writes anything.
|
||||
@@ -8091,7 +8179,13 @@ export class Broker {
|
||||
if (!s.tester && !s.user) this.#human(s);
|
||||
keys(a, ['project', 'name', 'instance'], ['project', 'name']);
|
||||
if (!name(a.name)) fail('invalid-request');
|
||||
const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
|
||||
// #1873 A3: a root or project copy takes a thread only when the caller names it, so leaving the
|
||||
// instance out still chooses among the organization file's own thread instances.
|
||||
const copies = b.copies ?? {};
|
||||
// #1873 A3: the code project the chat project maps to decides which agents work in it.
|
||||
const repo = this.#store.get('SELECT repo FROM projects WHERE id=? AND business=?', identifier(a.project), s.business)?.repo ?? null;
|
||||
const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r) &&
|
||||
(a.instance !== undefined || (!Object.hasOwn(copies, r) && !this.#projectRefused(b, r, repo))));
|
||||
const instance = a.instance === undefined
|
||||
? (instances.length === 1 ? instances[0] : null)
|
||||
: identifier(a.instance);
|
||||
@@ -8105,9 +8199,18 @@ export class Broker {
|
||||
fail('project-not-found');
|
||||
// #797 MVP3: an archived project refuses new threads (and the room's requests, above).
|
||||
if (this.#archived(s.business, identifier(a.project))) fail('project-archived');
|
||||
// #1873 A3: a named copy the boot left unbound or refused says why, and a project whose union
|
||||
// refuses takes no thread, naming the project and why.
|
||||
if (a.instance !== undefined && instance) this.#copyRefused(b, instance);
|
||||
const unionRefused = this.#unionRefused(b, repo);
|
||||
if (unionRefused) throw unionRefused;
|
||||
// The human selects an instance; omission is compatible only with one candidate.
|
||||
if (!instances.length || (a.instance !== undefined && !instances.includes(instance))) fail('thread-instance-missing');
|
||||
if (a.instance === undefined && instances.length > 1) fail('thread-instance-ambiguous');
|
||||
// #1873 A3: a project agent works only in its own project, and an agent the project excludes
|
||||
// doesn't work in it.
|
||||
const elsewhere = this.#projectRefused(b, instance, repo);
|
||||
if (elsewhere) throw elsewhere;
|
||||
if (this.#store.get('SELECT 1 FROM threads WHERE project=? AND name=?', a.project, a.name)) fail('thread-exists');
|
||||
const tid = randomUUID();
|
||||
this.#store.run(
|
||||
|
||||
@@ -16,6 +16,7 @@ export function busOrganization(business, resolved) {
|
||||
!r.limits?.authority
|
||||
)
|
||||
throw new BusError('invalid-resolution');
|
||||
out.roles[role].network = r.limits.network;
|
||||
out.roles[role].authority = structuredClone(r.limits.authority);
|
||||
// REQ-HARN-2: "web" in the resolved tool limit is the grant for the broker's web verbs.
|
||||
out.roles[role].web = (r.limits.tools ?? []).includes('web');
|
||||
|
||||
@@ -88,7 +88,7 @@ function launchOp(m) {
|
||||
if (m.op === 'request.release') return runtime.requestRelease(m.cap, m.record);
|
||||
if (m.op === 'request.manifest') return runtime.requestManifest(m.record);
|
||||
if (m.op === 'request.chief') return runtime.requestChief(m.business);
|
||||
if (m.op === 'request.admission') return runtime.admission({ business: m.business, project: m.project, projects: m.projects, thread: m.thread, session: m.session });
|
||||
if (m.op === 'request.admission') return runtime.admission({ business: m.business, project: m.project, projects: m.projects, instance: m.instance, revision: m.revision, thread: m.thread, session: m.session });
|
||||
if (m.op === 'credentialStatus') return runtime.credentialStatus(m.business, m.role);
|
||||
if (m.op === 'modelAdmission') return runtime.modelAdmission(m);
|
||||
if (m.op === 'modelPreflight') return runtime.modelPreflight(m);
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
// Static refusal table: each listed code maps to one rail and next step. These strings
|
||||
// never interpolate caller arguments, credential references, policy values or file bytes.
|
||||
const entries = [];
|
||||
const group = (codes, rail, nextStep) => {
|
||||
const guidance = Object.freeze({ rail, nextStep });
|
||||
for (const code of codes.split(' ')) {
|
||||
if (entries.some(([known]) => known === code)) throw new Error('duplicate refusal guidance');
|
||||
entries.push([code, guidance]);
|
||||
}
|
||||
};
|
||||
group(
|
||||
"access-refused access-revoked share-exists share-not-covered share-not-found tester-dev-only tester-off tester-refused tester-required tester-target-not-running thread-not-allowed thread-not-owned",
|
||||
"R7: caller access and scoped session boundary",
|
||||
"Use only the caller's own permitted scope. Ask the owner to inspect access or sharing through the supported controls; never borrow another identity."
|
||||
);
|
||||
group(
|
||||
"adapter-failed check-threw duplicate-run malformed-answer no-verdict not-a-merge not-approved not-collected not-human not-merge-option not-resolved not-thread-launch peercred-required reply-limits-unreadable socket-exists store-unreadable target-branch workspace-rename-failed",
|
||||
"R3: fail closed on an unsatisfied operation precondition",
|
||||
"Inspect the supported status and report the refusal code to the responsible owner; do not bypass it or retry an uncertain side effect."
|
||||
);
|
||||
group(
|
||||
"addressee-ambiguous invalid-approval-choice invalid-arbiter invalid-authority invalid-business invalid-choice invalid-class invalid-domain invalid-envelope invalid-harness invalid-messaging invalid-options invalid-plugins invalid-recommendation invalid-request invalid-snapshot invalid-spec-citation invalid-state invalid-task-ref invalid-timestamp name-is-instance name-is-role name-taken project-name-is-repo thread-instance-ambiguous unknown-action unknown-addressee unknown-business unknown-commit unknown-model unknown-plugin unknown-repo unknown-request unknown-requirement unknown-role unknown-run unknown-verb",
|
||||
"R3: validated vocabulary and request shape",
|
||||
"Check the tool schema and permitted identifiers, correct the request, then retry; do not guess around a refusal."
|
||||
);
|
||||
group(
|
||||
"agent-required",
|
||||
"Role/session: agent identity required",
|
||||
"Use this tool only from an authorized managed agent session."
|
||||
);
|
||||
group(
|
||||
"agent-running fire-in-progress instance-fired instance-firing instance-not-listed instance-retired instance-running instance-starting launch-record-mismatch launch-revoked launcher-released not-drained not-drained-run not-exited not-held not-holder not-recipient not-retired revoke-refused revoked role-already-held room-not-open room-thread-name-taken run-ended run-not-ended run-revoked session-clear session-clear-cancelled session-clear-complete session-clearing session-exists session-name-too-long session-not-found session-op-conflict session-retired session-revision-stale thread-already-held thread-exists thread-instance-mismatch thread-instance-missing thread-not-found thread-required thread-required-reply-to-thread-message thread-required-use-thread-send thread-revoked",
|
||||
"Role/session: current holder and lifecycle fence",
|
||||
"Read current session/status and ask the launcher or owner to finish the supported lifecycle transition. A stale or ended run must not reclaim another holder's identity."
|
||||
);
|
||||
group(
|
||||
"apikey-invalid callback-url-invalid harness-off harness-refused key-quota-no-model-key login-state-unknown model-catalogue-unavailable model-harness-incompatible model-key-collision model-provider-unavailable models-ambiguous models-invalid models-unconfirmed models-write-refused priority-invalid priority-unconfirmed priority-write-refused provider-not-allowed proxy-config-invalid proxy-down proxy-first-output-invalid proxy-init-failed proxy-key-invalid proxy-key-issue-failed proxy-key-refused proxy-key-revoking proxy-open proxy-revoke-revision-conflict proxy-rotate-grace-invalid proxy-uncertain thinking-unsupported",
|
||||
"R1/R3: owner-managed model and provider configuration",
|
||||
"Ask the owner to inspect the provider configuration through its supported controls; never put keys or callback contents in messages."
|
||||
);
|
||||
group(
|
||||
"block-recipient block-reply-required block-sender message-recipient-refused message-sender-refused tester-reply-required tester-thread-human user-reply-required user-thread-human",
|
||||
"Role messaging: permitted sender, recipient and reply context",
|
||||
"Read my_rails and use only a permitted target and the required reply context. Ask the owner if the permitted route is unavailable."
|
||||
);
|
||||
group(
|
||||
"command-not-granted credential-file credential-revoke-failed credential-unknown credential-leak gitea-reach-unknown gitea-reach-wider gitea-user-admin gitea-user-disabled gitea-user-foreign gitea-user-gone gitea-user-orphan grant-admin-missing grant-admin-nothing-left grant-admin-rotation-due grant-busy grant-chief-only grant-lost grant-revoke-failed grant-revoke-running grant-token-unknown grants-revoking remote-not-granted remote-host-mismatch remote-not-fork scope-changed",
|
||||
"R1/R8: scoped and revocable access",
|
||||
"Ask the owner to inspect the scoped access and binding state through the supported access controls; do not supply credentials in a call or widen your own grants."
|
||||
);
|
||||
group(
|
||||
"outcome-unknown outcome-unknown-revoked revision-conflict revision-conflict-revoked",
|
||||
"R2/R3: current revision and verified effect",
|
||||
"Read the latest status, revision and receipts before retrying. Verify whether an uncertain effect already happened, and use the current revision for any new attempt."
|
||||
);
|
||||
group(
|
||||
"confirm-required",
|
||||
"R2/R3: explicit confirmation of the current operation",
|
||||
"Read the current status and obtain the required confirmation through the supported human path."
|
||||
);
|
||||
group(
|
||||
"decision-closed decision-consumed decision-mismatch decision-not-approved decision-not-eligible decision-not-found decision-not-held decision-stale supersede-refused",
|
||||
"Role decisions: current, matching, approved and single-use decision",
|
||||
"Read the current decision, its route and its resolution and consumption receipts. Obtain the operation's matching approval from the permitted resolver before acting; a consumed approval cannot be reused."
|
||||
);
|
||||
group(
|
||||
"decision-not-allowed",
|
||||
"Role authority: no free-text decision channel",
|
||||
"Use the role's permitted message path to report the need to a person; do not use another decision channel."
|
||||
);
|
||||
group(
|
||||
"decision-required",
|
||||
"Role authority and R4: approval before acting",
|
||||
"Read my_rails. Raise a decision for the action and its permitted arbiter; hard waits go to a person. Use blocking false when there is no task."
|
||||
);
|
||||
group(
|
||||
"decision-route-refused",
|
||||
"Role messaging: decision close route",
|
||||
"Ask the owner to inspect the raiser and resolver messaging policy; do not route around the refused close path."
|
||||
);
|
||||
group(
|
||||
"definition-not-hireable instance-kept launch-not-configured no-chief-of-staff organization-busy organization-definition-refused organization-instance-referenced organization-instance-retired role-end-refused staff-request-refused",
|
||||
"Role configuration and protected instance controls",
|
||||
"Read the permitted configuration/status and ask the owner or configured chief to repair it through the supported path. Do not edit policy to bypass the refusal."
|
||||
);
|
||||
group(
|
||||
"field-writer task-already-archived task-archived task-conflict task-create-conflict task-create-uncertain task-not-archived task-not-found task-position-full task-project-refused task-scope-required task-subject-required",
|
||||
"Role task ownership, allowed fields and current task state",
|
||||
"Read the current task and assigned role. Use the permitted task operation with current evidence; ask the PM for scope or assignment changes through the decision path."
|
||||
);
|
||||
group(
|
||||
"task-required",
|
||||
"Role decisions: a blocking decision belongs to a task",
|
||||
"Supply the task_ref for this work, or raise with blocking false when there is no task."
|
||||
);
|
||||
group(
|
||||
"fork-foreign gitea-admin-unavailable gitea-failed gitea-not-configured gitea-not-declared host-busy host-changed host-creative-role host-enrolled host-failed host-in-use host-key-mismatch host-line-left host-not-configured host-not-declared host-not-enrolled host-not-granted host-not-ready host-remove-failed host-unknown publish-busy publish-unknown",
|
||||
"Role access: declared service, host or remote and verified outcome",
|
||||
"Check the supported access/status read and ask the owner to repair the declared service or grant. If an effect is uncertain, verify its receipt before retrying."
|
||||
);
|
||||
group(
|
||||
"human-required",
|
||||
"R4/R8: person-only operation",
|
||||
"A person must perform or approve this operation through its supported human path; an agent must not retry as a person."
|
||||
);
|
||||
group(
|
||||
"land-unknown no-push not-a-reviewer ref-moved self-review stale-commit verdict-exists verdict-not-approve verdict-not-found verdict-stale work-not-yours work-ref",
|
||||
"Role review and landing: independent verdict on the current work",
|
||||
"Read the latest work and review receipts. Obtain the required independent review or matching merge decision for the current commit before retrying."
|
||||
);
|
||||
group(
|
||||
"library-fetch-no-grant library-not-configured library-notice-store-failed library-write-no-grant library-write-refused web-failed web-not-configured web-not-granted",
|
||||
"Role tools: granted and configured web/library access",
|
||||
"Read my_rails and the permitted service status. Use only granted tools; ask the owner to repair unavailable service configuration."
|
||||
);
|
||||
group(
|
||||
"message-not-found no-subject not-assistant operation-not-found operation-not-open",
|
||||
"R7/role scope: visible object and allowed operation",
|
||||
"Use the caller's permitted reads to locate current context, then retry only the supported operation. Ask the owner if it remains unavailable."
|
||||
);
|
||||
group(
|
||||
"not-authorized",
|
||||
"Role authority: held action or approved decision",
|
||||
"Read my_rails and use the applicable decision path; ask the owner to inspect grants if no supported path exists."
|
||||
);
|
||||
group(
|
||||
"not-configured project-already-archived project-archived project-exists project-not-archived project-not-configured project-not-found project-record-invalid project-remote-configured project-remote-invalid project-remote-library project-remote-protected project-remote-unknown project-spec-human-only system-type-not-enabled tracker-not-store usage-not-configured",
|
||||
"R3/role scope: configured project and service state",
|
||||
"Read the supported project/service status and ask its owner to repair missing configuration or state before retrying."
|
||||
);
|
||||
group(
|
||||
"not-resolver",
|
||||
"Role resolve right and decision route",
|
||||
"Only the routed resolver holding the matching decision.resolve right may resolve. Read my_rails and notify that resolver."
|
||||
);
|
||||
group(
|
||||
"prd-arbiter-unavailable prd-authority-refused prd-changes-requested prd-decision-closed prd-not-approved prd-self-approval request-access-refused request-closed request-evidence-required request-expired request-failed request-not-configured request-not-found request-not-taken request-origin-refused request-project-refused request-revision-conflict request-selection-refused request-tracker-refused request-verification-refused requirement-snapshot-refused requirement-snapshot-required requirement-snapshot-revoked spec-unconfirmed stale-spec",
|
||||
"Role workflow: current admitted request and approved requirements",
|
||||
"Read the current request, requirements and decision state. Ask the responsible PM or routed arbiter to supply the missing current evidence before continuing."
|
||||
);
|
||||
group(
|
||||
"processing-audit-conflict processing-control-unsafe processing-uninitialized processing-generation-changed processing-halted processing-state-invalid storage-refused storage-unavailable",
|
||||
"R2/R3: available audited storage and processing state",
|
||||
"Stop the refused operation and report the processing or storage state to the owner. Resume only after the supported status shows the condition is repaired."
|
||||
);
|
||||
group(
|
||||
"read-only",
|
||||
"Role/session: read-only capability",
|
||||
"Use the permitted reads. Ask the owner or an authorized writer to perform the change."
|
||||
);
|
||||
group(
|
||||
"reserved-event",
|
||||
"R2: trusted append-only evidence",
|
||||
"Use the typed operation that records its own evidence; never emit a reserved event directly."
|
||||
);
|
||||
group(
|
||||
"role-not-chief",
|
||||
"R9: only the configured chief manages hires",
|
||||
"Send the staffing need to the configured chief through an allowed message path."
|
||||
);
|
||||
group(
|
||||
"unauthenticated",
|
||||
"R7: authenticated caller identity",
|
||||
"Ask the launcher to restore the caller's own authenticated session; never borrow another capability."
|
||||
);
|
||||
group(
|
||||
"request-timeout request-too-large incomplete-request invalid-response response-too-large unknown-tool tool-failed capacity-full",
|
||||
"R3/role tools: bounded protocol and available capacity",
|
||||
"Check the tool schema and narrow oversized requests. For a timeout, inspect status and receipts before retrying; never assume an uncertain operation did not happen."
|
||||
);
|
||||
// Finite error vocabularies forwarded by the project's, library's and request's
|
||||
// typed adapters are part of the same table, not an unhelpful unknown-code path.
|
||||
group(
|
||||
"project-busy library-sync-busy",
|
||||
"Role storage: exclusive write or sync already in progress",
|
||||
"Wait for the current operation to finish, read its status, then retry once the lock is clear."
|
||||
);
|
||||
group(
|
||||
"stale-base library-accept-stale",
|
||||
"R2/R3: current file or served revision",
|
||||
"Read the latest file or library status and its current revision. Reconcile the intended change before retrying with that base; never overwrite a newer change blindly."
|
||||
);
|
||||
group(
|
||||
"citation-cross-universe citation-invalid citation-missing citation-unavailable citation-unfetched citations-over-limit playground-cited-as-canon reply-invalid",
|
||||
"Role replies: validated citations, provenance and scope",
|
||||
"Use permitted library, spec or web reads and supply a valid reply with current citations in the allowed universe and scope. Narrow oversized citation lists; never invent provenance."
|
||||
);
|
||||
group(
|
||||
"invalid-request-admission invalid-request-effect invalid-request-event invalid-request-plan invalid-request-projects artifact-path-invalid artifact-type-refused",
|
||||
"R3: typed request plan, delivery and evidence schema",
|
||||
"Check the current request's tool schema and correct the plan, artifact path or evidence. Delivery requires the supported self-contained HTML artifact and matching verification evidence."
|
||||
);
|
||||
group(
|
||||
"prd-admission-revoked prd-author-invalid prd-candidate-invalid prd-candidate-required prd-candidate-size prd-candidate-stale prd-requirement-invalid",
|
||||
"Role PRD workflow: current candidate, authors and admission",
|
||||
"Read the current request, candidate, requirements, mode and approval. Ask the PM to correct or submit the current candidate through the supported workflow; never approve your own authorship."
|
||||
);
|
||||
group(
|
||||
"request-question-open request-question-closed request-question-mismatch request-phase-refused request-attachment-mismatch request-delivery-required request-document-refused",
|
||||
"Role request workflow: current phase, question and selected attachment",
|
||||
"Read intake_status and follow its current question, wait or delivery step. A pending question needs its answer before another question; use only the operation and attachment valid for the current phase."
|
||||
);
|
||||
group(
|
||||
"request-effect-conflict request-effect-unprepared request-mode-revision-conflict request-operation-conflict request-plan-binding-refused request-plan-path-collision request-plan-path-refused request-project-collision",
|
||||
"R2/R3: current request revision and bound operation",
|
||||
"Read the current request status, plan and operation receipts. Recover an existing operation before retrying; use a new operation ID only for a new intended operation, and never bypass a path or project collision."
|
||||
);
|
||||
group(
|
||||
"request-notification-unavailable request-services-unavailable library-credential-missing library-human-missing library-mapping-invalid library-problem-code library-remote-invalid library-state-invalid library-diverged library-accept-invalid",
|
||||
"R1/R3: owner-configured service, mapping and accepted library state",
|
||||
"Read the supported service or library status and ask the owner to repair the missing binding, mapping or accepted state. Check existing operation receipts before retrying an uncertain effect."
|
||||
);
|
||||
group(
|
||||
"library-commit library-git library-push-failed project-commit-uncertain project-init-cleanup",
|
||||
"R2/R3: verified repository effect and recovery",
|
||||
"Read current repository status and receipts to determine what already happened. Report an uncertain commit, push or cleanup to the owner before any new write; do not retry an uncertain effect blindly."
|
||||
);
|
||||
group(
|
||||
"project-author project-dirty project-git project-id project-invalid project-no-commit project-not-declared project-not-empty project-path project-read-commit project-read-content project-read-missing project-read-path project-record-path project-records-conflict project-records-missing project-remote project-repository tester-project-area",
|
||||
"Role project scope: declared repository, clean state and allowed paths",
|
||||
"Read the permitted project status and file metadata. Correct the declared project or allowed path when possible; ask the owner to repair repository state or identity. Preserve existing changes and never bypass repository or path checks."
|
||||
);
|
||||
group(
|
||||
"project-record-unchanged",
|
||||
"R2: no new record effect for unchanged content",
|
||||
"Read the current record and its existing commit receipt. If it already matches the intended content, use that evidence instead of retrying the same write."
|
||||
);
|
||||
group(
|
||||
"library-write-story-path library-revert-not-agent",
|
||||
"Role library writes: supporting paths and permitted agent-owned commits",
|
||||
"Use only the supporting paths and agent-owned commit reversals permitted by the library tool. Preserve story and canon files; ask the owner when the requested edit is outside that scope."
|
||||
);
|
||||
group(
|
||||
"query-argument-invalid query-chapter-ambiguous query-item-too-large query-universe-unknown",
|
||||
"Role library reads: known universe, unambiguous selection and bounded result",
|
||||
"Read the permitted library index, choose a known universe and an unambiguous book or chapter, and narrow the query or requested range before retrying."
|
||||
);
|
||||
export const REFUSALS = Object.freeze(Object.fromEntries(entries));
|
||||
const UNKNOWN = Object.freeze({ rail: "R3: fail closed", nextStep: "Report the refusal code to the responsible owner and inspect supported status; do not bypass the refusal." });
|
||||
export const refusalGuidance = code => Object.hasOwn(REFUSALS, code) ? REFUSALS[code] : UNKNOWN;
|
||||
@@ -35,7 +35,7 @@ import { instanceSettings } from '../../organization/src/instance-settings.mjs';
|
||||
import { organizationWriter, OrganizationError, setReplyLimits } from '../../organization/src/organization.mjs';
|
||||
import { getProjectRemote } from '../../organization/src/project-remotes.mjs';
|
||||
import { credentialVerbs } from './credential-verbs.mjs';
|
||||
import { loadBusiness } from '../../organization/src/organization-config.mjs';
|
||||
import { loadWithCopies } from '../../organization/src/agent-copies.mjs';
|
||||
import { loadRole } from '../../organization/src/role.mjs';
|
||||
import { resolveInstance } from '../../organization/src/resolve.mjs';
|
||||
import { canonicalJson } from '../../organization/src/util.mjs';
|
||||
@@ -284,7 +284,14 @@ export async function startBroker({
|
||||
builtinBinding = null,
|
||||
modelCatalogue = null, // in-process fixture seam; boot JSON cannot supply a service
|
||||
makeProviderLoader = createLoader, // in-process fixture seam; boot JSON cannot supply a function
|
||||
// #1873 A3: the boot's root and project agent copies (cli config.mjs copiesAtBoot), plain data. Every
|
||||
// reload of the organization file below re-applies this snapshot, never the root file.
|
||||
copies = null,
|
||||
}) {
|
||||
if (copies !== null && (typeof copies !== 'object' || Array.isArray(copies) || typeof copies.org !== 'string'
|
||||
|| !Object.hasOwn(businesses ?? {}, copies.org) || copies.copies === null || typeof copies.copies !== 'object' || Array.isArray(copies.copies)))
|
||||
throw new BusError('invalid-host-request');
|
||||
const loadOrg = id => loadWithCopies(id, { dir: organization.dir, rolesDir: organization.rolesDir, snapshot: copies?.org === id ? copies : null });
|
||||
if (!Array.isArray(consoleOwners) || consoleOwners.length > 512 || consoleOwners.some((o) => o === null || typeof o !== 'object' || Array.isArray(o) ||
|
||||
Object.keys(o).length !== 3 || ['provider', 'iss', 'sub'].some((k) => typeof o[k] !== 'string' || !o[k] || o[k].length > 512)))
|
||||
throw new BusError('invalid-host-request');
|
||||
@@ -564,7 +571,7 @@ export async function startBroker({
|
||||
const reloadLog = entry => process.stderr.write(`organization: ${JSON.stringify(entry)}\n`);
|
||||
function reload(id) {
|
||||
try {
|
||||
const b = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
|
||||
const b = loadOrg(id);
|
||||
const resolved = {};
|
||||
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: b, instance });
|
||||
const next = withoutPending(id, busOrganization(b, resolved));
|
||||
@@ -595,7 +602,7 @@ export async function startBroker({
|
||||
if (!found || found.after !== organization_revision) refuse('bootstrap-receipt-mismatch');
|
||||
// Only this bootstrap's own post-add revision may be admitted: any later organization write means the confirmed world moved (#800).
|
||||
if (revision !== found.after) refuse('bootstrap-revision-stale');
|
||||
const b = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
|
||||
const b = loadOrg(id);
|
||||
const resolved = {};
|
||||
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: b, instance });
|
||||
const full = busOrganization(b, resolved);
|
||||
@@ -622,7 +629,11 @@ export async function startBroker({
|
||||
// product the broker holds is ready only while it has a complete marker and a committed activate receipt: the
|
||||
// answer is read from both on every call, so a marker removed after the activation closes it again, and the
|
||||
// broker's reload now follows both readiness records (#802).
|
||||
function admission({ business: id, project, projects, thread, session }) {
|
||||
// With `instance` (#1873 A3), the launch boundary also asks whether that instance may work in each
|
||||
// project as the business stands now: a project that excludes it, or whose union refuses, is not ready.
|
||||
// `instance` comes with `revision`, the organization file revision the launch resolved its bundle
|
||||
// from; a file that has changed since (a narrow, a project define's vars, an exclude) is not ready.
|
||||
function admission({ business: id, project, projects, instance, revision: launched, thread, session }) {
|
||||
if (projects !== undefined) {
|
||||
const no = code => ({ ready: false, revision: null, code });
|
||||
if (typeof id !== 'string' || !Object.hasOwn(businesses, id) || !Array.isArray(projects) || projects.some(p => typeof p !== 'string')) return no('project-unknown');
|
||||
@@ -631,9 +642,21 @@ export async function startBroker({
|
||||
if (!row) return no('session-not-found');
|
||||
if (row.archived) return no('project-archived');
|
||||
}
|
||||
if (instance !== undefined && (typeof instance !== 'string' || typeof launched !== 'string')) return no('project-unknown');
|
||||
if (instance !== undefined) {
|
||||
let now;
|
||||
try { now = writer?.read(id).revision; } catch { return no('organization-unreadable'); }
|
||||
if (now === undefined) return no('organization-unreadable');
|
||||
if (now !== launched) return no('organization-changed');
|
||||
}
|
||||
for (const p of projects) {
|
||||
const result = admission({ business: id, project: p });
|
||||
if (!result.ready) return result;
|
||||
if (instance !== undefined) {
|
||||
// The broker holds the document the last writer reload swapped in; `businesses` is the boot one.
|
||||
const barred = broker.projectBarred(id, instance, p);
|
||||
if (barred) return no(barred);
|
||||
}
|
||||
}
|
||||
return { ready: true, revision: null, code: null };
|
||||
}
|
||||
@@ -753,7 +776,7 @@ export async function startBroker({
|
||||
// plugins.set reloads the business's limit checks in the background.
|
||||
organization:settings?(proxyOrganization?.wrap ?? (operation => operation))((id,verb,args,actor,context)=>{
|
||||
if (verb === 'project.remote.get.console' || verb === 'project.remote.set.console') {
|
||||
const current = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
|
||||
const current = loadOrg(id);
|
||||
const resolved = {};
|
||||
for (const instance of Object.keys(current.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: current, instance });
|
||||
const live = withoutPending(id, busOrganization(current, resolved));
|
||||
|
||||
@@ -9,11 +9,12 @@ const LIMIT = 65536;
|
||||
const PROJECT_SAVE_AT_START = new RegExp(`"verb":"${PROJECT_SAVE_VERB.replaceAll('.', '\\.')}"`);
|
||||
const LIBRARY_SAVE_AT_START = /"verb":"library\.file\.save"/;
|
||||
const SAVE_VERB_AT_START = { test: text => PROJECT_SAVE_AT_START.test(text) || LIBRARY_SAVE_AT_START.test(text) };
|
||||
// The refusal envelope. C2-06 (sage-3599): `reason` is added only when the error carries one, which
|
||||
// only explicitly scoped refusals do, so every other envelope is byte-identical to before.
|
||||
const refusal = (e, other) => e instanceof BusError
|
||||
? { ok: false, error: e.code, ...(typeof e.reason === 'string' ? { reason: e.reason } : {}) }
|
||||
: { ok: false, error: other };
|
||||
// Preserve every code and scoped reason, adding only static rail/next-step guidance.
|
||||
const refusal = (e, other) => {
|
||||
const error = e instanceof BusError ? e : new BusError(other);
|
||||
return { ok: false, error: error.code, rail: error.rail, nextStep: error.nextStep,
|
||||
...(typeof error.reason === 'string' ? { reason: error.reason } : {}) };
|
||||
};
|
||||
// One request per connection. There is deliberately no reconnect/retry or SQL verb.
|
||||
// `tasks` ({handle, timeout}) is the trusted S3 adapter; only the eight task verbs reach it.
|
||||
// `web` is the trusted web service (web.mjs), or a function from a business id to one (or null); only
|
||||
@@ -48,16 +49,16 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
|
||||
socket.end(JSON.stringify(result) + '\n');
|
||||
};
|
||||
socket.setTimeout(timeout, () => {
|
||||
finish({ ok: false, error: 'request-timeout' });
|
||||
finish(refusal(new BusError('request-timeout')));
|
||||
socket.destroySoon();
|
||||
});
|
||||
socket.on('data', async (chunk) => {
|
||||
if (done) return;
|
||||
if (processing) return finish({ ok: false, error: 'invalid-envelope' });
|
||||
if (processing) return finish(refusal(new BusError('invalid-envelope')));
|
||||
const total = input.length + chunk.length;
|
||||
if (total > LIMIT && !(total <= PROJECT_SAVE_REQUEST_MAX &&
|
||||
SAVE_VERB_AT_START.test(Buffer.concat([input, chunk]).subarray(0, 1024).toString('latin1')))) {
|
||||
finish({ ok: false, error: 'request-too-large' });
|
||||
finish(refusal(new BusError('request-too-large')));
|
||||
return;
|
||||
}
|
||||
input = Buffer.concat([input, chunk]);
|
||||
@@ -187,7 +188,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
|
||||
}
|
||||
});
|
||||
socket.on('end', () => {
|
||||
if (!done && !processing) finish({ ok: false, error: 'incomplete-request' });
|
||||
if (!done && !processing) finish(refusal(new BusError('incomplete-request')));
|
||||
});
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// #1873 Phase A slice A3: the broker half of root and project agent copies. The business carries the
|
||||
// boot's `copies` view (organization agent-copies.mjs withCopies): a bound copy is also in `roles`,
|
||||
// an unbound or refused one is not. The cli half, with real runners, is cli agent-copies.test.mjs.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { BusError, Broker } from '../src/broker.mjs';
|
||||
|
||||
const shipped = (name) => JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
|
||||
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
|
||||
const SECRET = 'sk-test-NOT-A-SECRET-1234';
|
||||
|
||||
const copy = (name, definition, project, state, extra = {}) => ({ name, level: project === null ? 'root' : 'project', project, definition, state, ...extra });
|
||||
const demo = () => ({
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'chief' },
|
||||
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging }, companion: { messaging: shipped('companion').messaging } },
|
||||
projects: { stack: { root: '/nonexistent/stack' }, web: { root: '/nonexistent/web' }, docs: { root: '/nonexistent/docs' } },
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', ...none },
|
||||
pm: { definition: 'pm', ...none },
|
||||
assistant: { definition: 'assistant', ...none },
|
||||
gate: { definition: 'merge-gate', ...none },
|
||||
'companion.stack': { definition: 'companion', ...none },
|
||||
buddy: { definition: 'companion', ...none },
|
||||
},
|
||||
copies: {
|
||||
gate: copy('gate', 'merge-gate', null, 'instance'),
|
||||
buddy: copy('buddy', 'companion', null, 'instance'),
|
||||
'companion.stack': copy('companion', 'companion', 'stack', 'instance'),
|
||||
researcher: copy('researcher', 'research', null, 'unbound', { missing: ['gitea'] }),
|
||||
wide: copy('wide', 'merge-gate', null, 'refused', { reason: 'model <value> is not in launch.models' }),
|
||||
'lint.web': copy('lint', 'security-reviewer', 'web', 'refused', { reason: 'project web refuses its agents: ghost is excluded, but nothing defines it' }),
|
||||
},
|
||||
// Each code project's union as withCopies computes it: web excludes buddy, docs refuses.
|
||||
unions: {
|
||||
stack: { ok: true, agents: ['assistant', 'buddy', 'chief', 'companion.stack', 'gate', 'pm'] },
|
||||
web: { ok: true, agents: ['assistant', 'chief', 'gate', 'pm'] },
|
||||
docs: { ok: false, reason: 'projects.docs.agents.ghost: names no root or organization agent to exclude' },
|
||||
},
|
||||
});
|
||||
|
||||
function setup(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-copies-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const b = new Broker({ store, businesses: { demo: demo() } });
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const call = (verb, args = {}) => b.request(human, { verb, args });
|
||||
const refusal = (fn) => {
|
||||
try { fn(); } catch (e) { assert.ok(e instanceof BusError, String(e)); return e; }
|
||||
assert.fail('no refusal');
|
||||
};
|
||||
const trail = () => store.all('SELECT kind, body FROM events').map((e) => `${e.kind} ${e.body}`).join('\n');
|
||||
return { b, store, call, refusal, trail };
|
||||
}
|
||||
|
||||
test('A3 bus: a bound copy takes a launch request; an unbound or refused one refuses at the verb by name, with the code alone in the trail', (t) => {
|
||||
const f = setup(t);
|
||||
assert.match(f.call('instance.launch', { instance: 'gate' }).request, /\S/);
|
||||
const unbound = f.refusal(() => f.call('instance.launch', { instance: 'researcher' }));
|
||||
assert.deepEqual([unbound.code, unbound.reason], ['credential-unbound', 'researcher has no binding in demo for gitea']);
|
||||
const refused = f.refusal(() => f.call('instance.launch', { instance: 'wide' }));
|
||||
assert.equal(refused.code, 'copy-refused');
|
||||
assert.match(refused.reason, /^wide can't launch in demo: /);
|
||||
const project = f.refusal(() => f.call('instance.launch', { instance: 'lint.web' }));
|
||||
assert.equal(project.code, 'project-refused');
|
||||
assert.match(project.reason, /^lint\.web can't launch in demo: project web refuses its agents: /);
|
||||
// Regenerate takes the same check.
|
||||
assert.equal(f.refusal(() => f.call('instance.regenerate', { instance: 'researcher' })).code, 'credential-unbound');
|
||||
// A name that is no copy and no instance is still unknown.
|
||||
assert.equal(f.refusal(() => f.call('instance.launch', { instance: 'nobody' })).code, 'unknown-role');
|
||||
// The trail holds each refusal's code, never its reason.
|
||||
const trail = f.trail();
|
||||
assert.match(trail, /credential-unbound/);
|
||||
for (const text of ['has no binding', "can't launch", 'refuses its agents', SECRET]) assert.equal(trail.includes(text), false, text);
|
||||
});
|
||||
|
||||
test('A3 bus: a project agent takes a thread only in its own project, and only when named', (t) => {
|
||||
const f = setup(t);
|
||||
const stack = f.call('project.create', { name: 'stack work', repo: 'stack' }).id;
|
||||
const web = f.call('project.create', { name: 'web work', repo: 'web' }).id;
|
||||
const loose = f.call('project.create', { name: 'loose' }).id;
|
||||
// Unnamed, the organization file's own thread instance is still the one choice.
|
||||
const plain = f.call('thread.create', { project: stack, name: 'plain' }).id;
|
||||
assert.equal(f.store.get('SELECT instance FROM threads WHERE id=?', plain).instance, 'assistant');
|
||||
const own = f.call('thread.create', { project: stack, name: 'own', instance: 'companion.stack' });
|
||||
assert.match(own.id, /\S/);
|
||||
for (const project of [web, loose])
|
||||
assert.equal(f.refusal(() => f.call('thread.create', { project, name: 'elsewhere', instance: 'companion.stack' })).code, 'project-agent-elsewhere');
|
||||
// A named copy the boot refused says why, before the thread instance check.
|
||||
const unbound = f.refusal(() => f.call('thread.create', { project: stack, name: 'r', instance: 'researcher' }));
|
||||
assert.deepEqual([unbound.code, unbound.reason], ['credential-unbound', 'researcher has no binding in demo for gitea']);
|
||||
});
|
||||
|
||||
test('A3 bus: a room holds only the thread instances that work in its project; an excluded agent takes no thread there; a refused union takes none at all, naming the project', (t) => {
|
||||
const f = setup(t);
|
||||
const project = (repo) => f.call('project.create', { name: `${repo ?? 'loose'} work`, ...(repo ? { repo } : {}) }).id;
|
||||
const [stack, web, docs, loose] = [project('stack'), project('web'), project('docs'), project(null)];
|
||||
const members = (id) => f.call('room.open', { project: id }).threads.map((x) => x.instance).sort();
|
||||
// Another project's agent never joins, and the project's own exclusion holds.
|
||||
assert.deepEqual(members(stack), ['assistant', 'buddy', 'companion.stack']);
|
||||
assert.deepEqual(members(web), ['assistant']);
|
||||
assert.deepEqual(members(loose), ['assistant', 'buddy']);
|
||||
assert.deepEqual(f.call('room.send', { project: web, body: 'hello' }).to, ['assistant']);
|
||||
const excluded = f.refusal(() => f.call('thread.create', { project: web, name: 'b', instance: 'buddy' }));
|
||||
assert.deepEqual([excluded.code, excluded.reason], ['project-refused', "buddy can't work in project web: the project excludes it"]);
|
||||
// docs's union refuses: no thread, named or not, and no room, each naming docs and why.
|
||||
for (const fn of [
|
||||
() => f.call('thread.create', { project: docs, name: 'plain' }),
|
||||
() => f.call('thread.create', { project: docs, name: 'a', instance: 'assistant' }),
|
||||
() => f.call('room.open', { project: docs }),
|
||||
]) {
|
||||
const e = f.refusal(fn);
|
||||
assert.equal(e.code, 'project-refused');
|
||||
assert.match(e.reason, /^project docs refuses its agents: .*ghost/);
|
||||
}
|
||||
assert.equal(f.trail().includes('refuses its agents'), false, 'the trail holds the code only');
|
||||
});
|
||||
@@ -37,7 +37,9 @@ const baselines = {
|
||||
// #1839: artifact Console replies alone may use 2 MiB; the proof and other limits are unchanged.
|
||||
// #1873 U1: humanCall, busCaller, userCall and userCaller take an optional signal and absolute
|
||||
// deadline; userCall shares transport(). Nonces, argv and limits are unchanged.
|
||||
"packages/webui/src/bus.mjs": "3ef8064dd7911f9eee4475b52c74a1d551583a065474b1bcee968cd45f722747",
|
||||
// #1873 A3: STATUS maps credential-unbound, copy-refused, project-refused and project-agent-elsewhere
|
||||
// to 409; no proof or transport change.
|
||||
"packages/webui/src/bus.mjs": "d8f3e0eb5299fdb192e98a1238ca693aae7c36af9a21de72d5f5d39610589377",
|
||||
};
|
||||
const repoFile = path => new URL("../../../" + path, import.meta.url);
|
||||
|
||||
|
||||
@@ -282,7 +282,7 @@ test('REQ-LIMIT-1: the chief of staff is logged as allowed, and organization.set
|
||||
const outside = [
|
||||
{ launch: { by: 'coder' } },
|
||||
{ human: 'mallory' },
|
||||
{ arbiters: { technical: 'coder', delivery: 'coder' } },
|
||||
{ arbiters: { technical: 'chief', delivery: 'chief' } },
|
||||
{ roles: {} },
|
||||
{ actor: 'jason' },
|
||||
{ 'launch.by': 'coder' },
|
||||
@@ -1026,7 +1026,7 @@ test('row 194 T8, T9, T18: through the runtime the chief hires and fires with th
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-chief-hire-'));
|
||||
const rolesDir = rolesCopy(root);
|
||||
// A hireable scratch definition: version 2, no credentials, every action on HIRE_ALLOWED.
|
||||
writeFileSync(join(rolesDir, 'aide.md'), '# Aide\n');
|
||||
writeFileSync(join(rolesDir, 'aide.md'), '# Aide\n\n## Authority\n');
|
||||
writeJson(join(rolesDir, 'aide.json'), {
|
||||
roleVersion: 2, name: 'aide', title: 'Aide', contract: 'aide.md', tools: ['read'], network: 'api-only',
|
||||
authority: { withinRole: ['message.send'], crossRole: [] }, credentials: [],
|
||||
|
||||
@@ -4,6 +4,7 @@ import { connect } from 'node:net';
|
||||
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { Client } from '../src/client.mjs';
|
||||
import { refusalGuidance } from '../src/refusals.mjs';
|
||||
import { serve } from '../src/server.mjs';
|
||||
import { FIXED_TEXTS, GuardError } from '../../library/src/guard.mjs';
|
||||
import { git } from '../../library/tests/helpers.mjs';
|
||||
@@ -138,7 +139,7 @@ test('C2-06 page store is session-local, caps 20 fetches and 200 KiB, drops at d
|
||||
await assert.rejects(f.call(replacement, 'message.send', { to: 'human', body: citedBody([{ url: 'https://fixture.invalid/21', quote: 'xxxx' }]) }), { code: 'citation-unfetched' });
|
||||
});
|
||||
|
||||
test('C2-06 transport passes safe capped guard reason; ordinary refusal envelope is byte-identical', async t => {
|
||||
test('C2-06 transport passes safe capped guard reason; ordinary refusal retains its code and adds static rail guidance', async t => {
|
||||
const f = citationWorld(t, { guard: async () => { throw new GuardError('citation-invalid', 'guard reason '.repeat(50)); } });
|
||||
f.call(f.agent, 'role.claim'); const path = join(f.root, 'socket');
|
||||
const server = await serve({ broker: f.broker, path }); t.after(() => server.close());
|
||||
@@ -148,7 +149,7 @@ test('C2-06 transport passes safe capped guard reason; ordinary refusal envelope
|
||||
const socket = connect(path); let text = ''; socket.on('error', reject); socket.on('data', b => { text += b; }); socket.on('end', () => resolve(text));
|
||||
socket.on('connect', () => socket.write(JSON.stringify({ cap: f.agent, verb: 'unknown-fixture' }) + '\n'));
|
||||
});
|
||||
assert.equal(raw, '{"ok":false,"error":"unknown-verb"}\n');
|
||||
assert.deepEqual(JSON.parse(raw), { ok: false, error: 'unknown-verb', ...refusalGuidance('unknown-verb') });
|
||||
});
|
||||
|
||||
test('C2-06 non-library businesses retain role.claim shape and unguarded free sends', async t => {
|
||||
|
||||
@@ -18,8 +18,8 @@ export function citationWorld(t, { definition = 'coder', library = true, guard:
|
||||
writeFileSync(settings, JSON.stringify({ replyLimits: { requireCitation: false, citationsPerReply: 20 } }));
|
||||
const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true },
|
||||
...(library ? { projects: { books: map } } : {}),
|
||||
arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { definition, web: true, authority: { withinRole: sendAuthority ? ['message.send'] : [], crossRole: [] } } } };
|
||||
arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition, web: true, authority: { withinRole: sendAuthority ? ['message.send'] : [], crossRole: [] } } } };
|
||||
const limits = () => {
|
||||
try { return resolveReplyLimits({ readable: true, document: JSON.parse(readFileSync(settings, 'utf8')) }); }
|
||||
catch { return resolveReplyLimits({ readable: false }); }
|
||||
|
||||
@@ -18,8 +18,8 @@ test('C2-06 runtime reads changed and unreadable reply limits without restarting
|
||||
// Runtime takes the host's broker projection, not the business validator's unresolved role entries.
|
||||
// No agent action is needed here: use the same empty-authority shape as limits-runtime.test.mjs.
|
||||
const business = { id: 'acme', human: 'jason', projects: checked.projects,
|
||||
arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
|
||||
arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
|
||||
const runtime = await startBroker({ dataRoot, businesses: { acme: business }, organization: { dir, rolesDir, system: systemFor(root) } });
|
||||
t.after(() => runtime.close());
|
||||
const human = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
|
||||
@@ -48,8 +48,8 @@ function setup(t) {
|
||||
human: 'jason',
|
||||
arbiters: { technical: 'pm', delivery: 'pm' },
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
pm: { definition: 'pm', authority: { withinRole: ['message.send', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
},
|
||||
vars: { 'bus.tester': true },
|
||||
};
|
||||
@@ -227,7 +227,7 @@ test('ordinary PRD approval and nine non-yes/no choices use the same protocol wi
|
||||
test('ordinary role routes refuse Console resolution and agent answers retain the actual role/run', (t) => {
|
||||
const f = setup(t);
|
||||
for (const route of ['pm', 'chief']) {
|
||||
const id = grantDecision(f.store, { action: 'prd.approve', route, close: null });
|
||||
const id = grantDecision(f.store, { action: 'policy.change', route, close: null });
|
||||
refusesSync(() => f.call('console', 'decision.preview.console', { id }), 'decision-not-eligible');
|
||||
refusesSync(() => f.call('console', 'decision.resolve.console', { id, choice: 'yes', digest: '0'.repeat(64) }), 'decision-not-eligible');
|
||||
f.call(route, 'decision.resolve', { id, choice: 'yes' });
|
||||
|
||||
@@ -30,8 +30,8 @@ async function setup(t, { storeRoot = true } = {}) {
|
||||
const store = (agent, service) => join(root, 'secrets', 'agents', 'demo', agent, service);
|
||||
// The bus's resolved business carries the same references; a store one names its derived file.
|
||||
const demo = {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: { 'bus.tester': true },
|
||||
roles: {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
coder: { ...role, credentials: { gitea: { store: true, file: store('coder', 'gitea'), rotateBy: DATE }, vikunja: doc.roles.coder.credentials.vikunja } },
|
||||
reviewer: { ...role, credentials: doc.roles.reviewer.credentials },
|
||||
},
|
||||
@@ -58,7 +58,7 @@ test('row 199: the lifecycle verbs refuse every session but the human, and the t
|
||||
const calls = [];
|
||||
const b = new Broker({
|
||||
store: s,
|
||||
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: { 'bus.tester': true }, roles: { coder: role } } },
|
||||
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: role } } },
|
||||
credentialVerbs: (id, verb, args, actor) => (calls.push([verb, actor]), { ok: true }),
|
||||
});
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
@@ -97,7 +97,7 @@ test('row 199: the lifecycle verbs refuse every session but the human, and the t
|
||||
b.request(tester, { verb: 'credentials.revoke', args: args['credentials.revoke'] });
|
||||
assert.deepEqual(calls, [['credentials.issue', 'jason'], ['credentials.revoke', 'jason'], ['credentials.delete', 'jason'], ['credentials.revoke', 'tester']]);
|
||||
// No lifecycle without the runtime's store.
|
||||
const bare = new Broker({ store: s, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: role } } } });
|
||||
const bare = new Broker({ store: s, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: role } } } });
|
||||
const h2 = bare.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
assert.throws(() => bare.request(h2, { verb: 'credentials.revoke', args: args['credentials.revoke'] }), /not-configured/);
|
||||
});
|
||||
@@ -182,8 +182,8 @@ test('#1886: a store root that exists with loose modes still refuses start', asy
|
||||
mkdirSync(join(root, 'secrets', 'agents'), { recursive: true, mode: 0o750 });
|
||||
chmodSync(join(root, 'secrets', 'agents'), 0o750);
|
||||
const demo = {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: {},
|
||||
roles: { coder: { ...role, credentials: { gitea: { store: true, file: join(root, 'secrets', 'agents', 'demo', 'coder', 'gitea'), rotateBy: DATE } } } },
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: {},
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { ...role, credentials: { gitea: { store: true, file: join(root, 'secrets', 'agents', 'demo', 'coder', 'gitea'), rotateBy: DATE } } } },
|
||||
};
|
||||
await assert.rejects(startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } }), /credential-file/);
|
||||
});
|
||||
@@ -24,8 +24,8 @@ async function setup(t, limit = 0) {
|
||||
writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'),
|
||||
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: ROLES, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } },
|
||||
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, ...ROLES }, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } },
|
||||
organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
return { bus, human: bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) };
|
||||
@@ -76,8 +76,8 @@ test('REQ-LIMIT-4 bindLaunch keeps the account off session.launched, refuses one
|
||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-daily-bind-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const broker = new Broker({ store, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: ROLES, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } } });
|
||||
const broker = new Broker({ store, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, ...ROLES }, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } } });
|
||||
const base = { business: 'demo', role: 'coder', harness: 'claude-code', provider: 'anthropic', model: 'opus' };
|
||||
for (const [i, account] of ['Jason Max', '', 5, {}, '-x'].entries())
|
||||
assert.throws(() => broker.bindLaunch({ ...base, run: `bad${i}`, account }), { code: 'invalid-request' }, JSON.stringify(account));
|
||||
|
||||
@@ -13,8 +13,8 @@ const options = [
|
||||
{ key: 'no', text: 'Decline' },
|
||||
];
|
||||
const roles = {
|
||||
pm: { authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
cto: { authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
pm: { authority: { withinRole: ['decision.resolve.delivery', 'message.send'], crossRole: [] } },
|
||||
cto: { authority: { withinRole: ['decision.resolve.technical', 'message.send'], crossRole: [] } },
|
||||
coder: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } },
|
||||
};
|
||||
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles } };
|
||||
|
||||
@@ -22,8 +22,8 @@ const options = [
|
||||
];
|
||||
const pusher = { authority: { withinRole: ['message.send'], crossRole: ['git.push.working'] } };
|
||||
const roles = {
|
||||
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
pm: { definition: 'pm', authority: { withinRole: ['message.send', 'decision.resolve.technical'], crossRole: [] } },
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
// Hears from pm, the technical arbiter, but not from the chief of staff, the delivery arbiter.
|
||||
coder: { definition: 'coder', ...pusher },
|
||||
|
||||
@@ -13,8 +13,8 @@ const options = [
|
||||
{ key: 'no', text: 'Decline' },
|
||||
];
|
||||
const roles = {
|
||||
pm: { authority: { withinRole: ['message.send', 'task.create'], crossRole: [] } },
|
||||
cto: { authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
pm: { authority: { withinRole: ['decision.resolve.delivery', 'message.send', 'task.create'], crossRole: [] } },
|
||||
cto: { authority: { withinRole: ['decision.resolve.technical', 'message.send'], crossRole: [] } },
|
||||
coder: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } },
|
||||
};
|
||||
const businesses = {
|
||||
|
||||
@@ -48,7 +48,7 @@ function setup(t, { keygen, check, hang = false } = {}) {
|
||||
arbiters: { technical: 'pm', delivery: 'pm' },
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
pm: { definition: 'pm', authority: { withinRole: ['message.send', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
},
|
||||
vars: { 'bus.tester': true },
|
||||
};
|
||||
|
||||
@@ -17,8 +17,8 @@ test('row 298 runtime: human scope approval writes the file and organization his
|
||||
const file = writeJson(join(root, 'businesses/demo.json'), businessDoc(root, 'demo'));
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
runtime = await startBroker({ dataRoot: join(root, 'data'),
|
||||
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } },
|
||||
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } },
|
||||
organization: { dir: root, rolesDir, system: systemFor(root) },
|
||||
});
|
||||
const human = runtime.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
|
||||
@@ -27,8 +27,8 @@ test('REQ-WAIT-2: a business granting a hard wait at either class is refused; th
|
||||
const root = mkdtempSync(join(tmpdir(), 'hard-waits-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const business = authority => ({ demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { definition: 'coder', authority } } } });
|
||||
const business = authority => ({ demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority } } } });
|
||||
for (const action of WAITS) {
|
||||
assert.throws(() => new Broker({ store, businesses: business({ withinRole: [action], crossRole: [] }) }), /invalid-authority/, action);
|
||||
assert.throws(() => new Broker({ store, businesses: business({ withinRole: [], crossRole: [action] }) }), /invalid-authority/, action);
|
||||
@@ -54,19 +54,19 @@ test('REQ-DEC-2: every hard wait goes to the human, cited or not, and the arbite
|
||||
}
|
||||
}
|
||||
const d = await f.raise({ action: 'data.delete', ...cite(f) });
|
||||
assert.throws(() => f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/);
|
||||
assert.throws(() => f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/);
|
||||
f.call(f.human, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
});
|
||||
|
||||
test('REQ-DEC-2: a gated raise citing the current spec goes to the arbiter for its domain; uncited, to the human', async t => {
|
||||
const f = await fixture(t, { arbiters: { technical: 'cto', delivery: 'reviewer' },
|
||||
roles: { cto: { definition: 'cto', authority: { withinRole: ['message.send'], crossRole: [] } } } });
|
||||
const f = await fixture(t, { arbiters: { technical: 'cto', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, cto: { definition: 'cto', authority: { withinRole: ['message.send', 'decision.resolve.technical'], crossRole: [] } } } });
|
||||
f.agent('cto');
|
||||
for (const action of LEFT) {
|
||||
const raise = { action, ...(action === 'role.revoke' ? { target: 'reviewer' } : {}) };
|
||||
assert.deepEqual(routed(f, f.raise(raise)), { class: 'gated', route_to: 'human' }, action);
|
||||
assert.deepEqual(routed(f, await f.raise({ ...raise, ...cite(f), domain: 'technical' })), { class: 'gated', route_to: 'cto' }, action);
|
||||
assert.deepEqual(routed(f, await f.raise({ ...raise, ...cite(f) })), { class: 'gated', route_to: 'reviewer' }, action);
|
||||
assert.deepEqual(routed(f, await f.raise({ ...raise, ...cite(f), domain: 'technical' })), { class: 'gated', route_to: action === 'policy.change' ? 'chief' : 'cto' }, action);
|
||||
assert.deepEqual(routed(f, await f.raise({ ...raise, ...cite(f) })), { class: 'gated', route_to: action === 'git.merge.protected' ? 'cto' : 'chief' }, action);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -74,15 +74,15 @@ test('REQ-DEC-2: an arbiter-approved cited gated decision authorizes the raiser,
|
||||
const f = await fixture(t, { roles: { pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } } } });
|
||||
f.agent('pm');
|
||||
const d = await f.raise({ action: 'role.revoke', target: 'pm', ...cite(f) });
|
||||
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'reviewer' });
|
||||
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'chief' });
|
||||
assert.throws(() => f.call(f.human, 'decision.resolve', { id: d.id, choice: 'yes' }), /agent-required/);
|
||||
f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
assert.deepEqual(f.call(f.coder, 'role.revoke', { role: 'pm', decision: d.id }), { revoked: true });
|
||||
});
|
||||
|
||||
test('REQ-DEC-2: a cited merge raised by merge-gate goes to the technical arbiter; a merge raised without the grant routes by citation', async t => {
|
||||
const f = await fixture(t, { arbiters: { technical: 'cto', delivery: 'reviewer' }, roles: {
|
||||
cto: { definition: 'cto', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
const f = await fixture(t, { arbiters: { technical: 'cto', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
cto: { definition: 'cto', authority: { withinRole: ['message.send', 'decision.resolve.technical'], crossRole: [] } },
|
||||
'merge-gate': { definition: 'merge-gate', authority: { withinRole: [], crossRole: ['git.merge.protected'] } },
|
||||
} });
|
||||
const cto = f.agent('cto'), gate = f.agent('merge-gate');
|
||||
@@ -106,7 +106,7 @@ test('REQ-DEC-2: a stale or unconfirmed citation refuses without a decision; an
|
||||
await assert.rejects(f.raise({ action: 'policy.change', ...old, spec_commit: edit }), { code: 'spec-unconfirmed' });
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, 0);
|
||||
const current = f.save('# Current\n');
|
||||
const own = await f.call(f.reviewer, 'decision.raise', { action: 'policy.change', question: 'Change?',
|
||||
const own = await f.call(f.chief, 'decision.raise', { action: 'policy.change', question: 'Change?',
|
||||
options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false,
|
||||
project: 'stack', spec_path: 'spec/north-star.md', spec_commit: current });
|
||||
assert.deepEqual(routed(f, own), { class: 'gated', route_to: 'human' });
|
||||
@@ -136,7 +136,7 @@ const allowedCount = f => f.store.get("SELECT count(*) n FROM events WHERE kind=
|
||||
test('Q169: the chief of staff\'s end of the assistant stops every thread run; its own run and another ender\'s are refused', async t => {
|
||||
const f = await fixture(t, { roles: { ...seats,
|
||||
chief: { definition: 'chief-of-staff', ...grants(['role.end']) }, lead: { definition: 'lead', ...grants(['role.end']) } } });
|
||||
const chief = f.agent('chief'), lead = f.agent('lead');
|
||||
const chief = f.chief, lead = f.agent('lead');
|
||||
// No thread is held yet, so there is nothing to end.
|
||||
assert.throws(() => f.call(chief, 'role.revoke', { role: 'assistant', reason: 'Q169' }), /not-held/);
|
||||
const a = held(f, 'a-run'), b = held(f, 'b-run');
|
||||
@@ -158,11 +158,11 @@ test('Q169: the chief of staff\'s end of the assistant stops every thread run; i
|
||||
|
||||
test('Q169: a revoke of the assistant the chief of staff raises goes to the human, cited or not, and no arbiter resolves it', async t => {
|
||||
const f = await fixture(t, { roles: { ...seats, chief: { definition: 'chief-of-staff', ...grants(['role.end']) } } });
|
||||
const chief = f.agent('chief');
|
||||
const chief = f.chief;
|
||||
for (const args of [{}, cite(f), { ...cite(f), domain: 'technical' }, { ...cite(f), domain: 'delivery' }]) {
|
||||
const d = await revoke(f, chief, 'assistant', args);
|
||||
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'human' }, JSON.stringify(args));
|
||||
assert.throws(() => f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/);
|
||||
assert.throws(() => f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/);
|
||||
}
|
||||
// Resolved by the human, the chief's revoke citing it stops the thread run, once.
|
||||
const a = held(f, 'a-run');
|
||||
@@ -181,8 +181,8 @@ test('Q169: another role\'s cited revoke of the assistant, resolved by an arbite
|
||||
// One raised while the instance's definition wasn't protected went to the arbiter. The same
|
||||
// store under a business that now makes it the assistant still refuses it.
|
||||
const d = await revoke(f, f.coder, 'pm', cite(f));
|
||||
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'reviewer' });
|
||||
f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'chief' });
|
||||
f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
const now = { ...f.business, roles: { ...f.business.roles, pm: { definition: 'assistant', ...grants([]) } } };
|
||||
const later = new Broker({ store: f.store, businesses: { demo: now } });
|
||||
const coder = later.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi', address: 'coder' });
|
||||
@@ -192,7 +192,7 @@ test('Q169: another role\'s cited revoke of the assistant, resolved by an arbite
|
||||
test('Q169: a revoke of the assistant citing a decision the human resolved stops its thread run', async t => {
|
||||
const f = await fixture(t, { roles: seats });
|
||||
const d = await revoke(f, f.coder, 'assistant', cite(f));
|
||||
assert.throws(() => f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/);
|
||||
assert.throws(() => f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/);
|
||||
f.call(f.human, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
// Row 155: the recorded holder run (null) and the role_claims one (none) compare equal, so the
|
||||
// revoke reaches the thread claims and stops the held one.
|
||||
@@ -205,8 +205,8 @@ test('Q169: a cited revoke of a role not in END_PROTECTED still goes to the arbi
|
||||
const f = await fixture(t, { roles: seats });
|
||||
f.agent('pm');
|
||||
const d = await revoke(f, f.coder, 'pm', cite(f));
|
||||
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'reviewer' });
|
||||
f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'chief' });
|
||||
f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
assert.deepEqual(f.call(f.coder, 'role.revoke', { role: 'pm', decision: d.id }), { revoked: true });
|
||||
});
|
||||
|
||||
@@ -216,7 +216,7 @@ test('Q169: a role granted role.revoke at either class still sends a revoke of t
|
||||
boss: { definition: 'boss', ...grants(['role.revoke']) } } });
|
||||
const lead = f.agent('lead'), boss = f.agent('boss');
|
||||
assert.deepEqual(routed(f, await revoke(f, lead, 'assistant', cite(f))), { class: 'gated', route_to: 'human' });
|
||||
assert.deepEqual(routed(f, await revoke(f, lead, 'pm', cite(f))), { class: 'cross-role', route_to: 'reviewer' });
|
||||
assert.deepEqual(routed(f, await revoke(f, lead, 'pm', cite(f))), { class: 'cross-role', route_to: 'chief' });
|
||||
const own = await revoke(f, boss, 'assistant', { choice: 'yes' });
|
||||
assert.deepEqual(routed(f, own), { class: 'gated', route_to: 'human' });
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decision_events WHERE decision=?', own.id).n, 0);
|
||||
|
||||
@@ -12,7 +12,7 @@ export const HUMAN_YES = { op: 'resolved', by: 'jason', choice: 'yes', via: 'cli
|
||||
|
||||
// A credential.mint decision with `grant` in its raise body. `close` is the decision_events row, or null
|
||||
// for an open decision.
|
||||
export function grantDecision(store, { business = 'demo', action = 'credential.mint', route = 'human', grant, close = HUMAN_YES }) {
|
||||
export function grantDecision(store, { business = 'demo', action = 'credential.mint', route = 'human', domain = 'delivery', grant, close = HUMAN_YES }) {
|
||||
const id = randomUUID();
|
||||
store.run(
|
||||
'INSERT INTO decisions(id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
@@ -21,7 +21,7 @@ export function grantDecision(store, { business = 'demo', action = 'credential.m
|
||||
store.run(
|
||||
"INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,'action.allowed',?,?,?,?)",
|
||||
randomUUID(), AT, business, 'chief', 'chief-run', id,
|
||||
JSON.stringify({ decision: id, action, operation: 'decision.raise', approvalChoice: 'yes', ...(grant ? { grant } : {}) }),
|
||||
JSON.stringify({ decision: id, action, operation: 'decision.raise', ...(domain === null ? {} : { domain }), approvalChoice: 'yes', ...(grant ? { grant } : {}) }),
|
||||
);
|
||||
if (close)
|
||||
store.run('INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)', id, AT, close.op, close.by, close.choice ?? null, close.via ?? null);
|
||||
|
||||
@@ -572,8 +572,8 @@ test('T27: the bus start sweeps <configDir>/secrets/run before it listens, and a
|
||||
await new Promise((r) => dead.on('close', r));
|
||||
writeFileSync(join(run, 'h-dddddd', 'pids'), `${dead.pid} x\n`);
|
||||
const demo = {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { authority: { withinRole: ['message.send'], crossRole: [] }, credentials: {} } },
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: ['message.send'], crossRole: [] }, credentials: {} } },
|
||||
};
|
||||
const start = () => startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
const bus = await start();
|
||||
@@ -640,8 +640,8 @@ test('M3: a host_grants record gives Credentials a store reference at start, and
|
||||
mkdirSync(join(root, 'secrets', 'agents'), { recursive: true, mode: 0o700 });
|
||||
chmodSync(join(root, 'secrets'), 0o700);
|
||||
const demo = {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { authority: { withinRole: ['message.send'], crossRole: [] }, credentials: {} } },
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: ['message.send'], crossRole: [] }, credentials: {} } },
|
||||
};
|
||||
const start = () => startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
const hostKeys = (bus) => bus.credentials.status().filter((c) => c.service.startsWith('host.'));
|
||||
|
||||
@@ -65,7 +65,7 @@ test('row 165 runtime: an add and a change reach the organization file with the
|
||||
const rolesDir = rolesCopy(root);
|
||||
writeJson(join(root, 'businesses/demo.json'), businessDoc(root, 'demo'));
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const file = join(root, 'businesses/demo.json');
|
||||
|
||||
@@ -26,7 +26,7 @@ const A = 'a'.repeat(40);
|
||||
const B = 'b'.repeat(40);
|
||||
const C = 'c'.repeat(40);
|
||||
const roles = {
|
||||
pm: { authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
pm: { authority: { withinRole: ['message.send', 'decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
coder: { authority: { withinRole: ['git.push.working', 'review.request', 'message.send'], crossRole: [] } },
|
||||
reviewer: { authority: { withinRole: ['review.verdict', 'message.send'], crossRole: [] } },
|
||||
'merge-gate': { authority: { withinRole: [], crossRole: [MERGE] } },
|
||||
@@ -221,15 +221,15 @@ test('REQ-LAND-1: a within-role merge grant refuses not-authorized at the land',
|
||||
assert.deepEqual([...w.merges(), ...w.events('action.refused')], []);
|
||||
});
|
||||
|
||||
test('a cross-role merge that routes to the human, because merge-gate is its own arbiter, lands on the human\'s CLI answer', (t) => {
|
||||
const w = setup(t, { arbiters: { technical: 'merge-gate', delivery: 'merge-gate' } });
|
||||
test('a cross-role merge that routes to the human, because the cto lander is its own arbiter, lands on the human\'s CLI answer', (t) => {
|
||||
const w = setup(t, { arbiters: { technical: 'cto', delivery: 'pm' }, roles: { ...roles, cto: { authority: { withinRole: ['decision.resolve.technical'], crossRole: [MERGE] } } } });
|
||||
w.push(A);
|
||||
w.approve(A);
|
||||
const d = w.raise();
|
||||
const d = w.raise('cto');
|
||||
assert.deepEqual(w.routed(d), { class: 'cross-role', route_to: 'human' });
|
||||
assert.throws(() => w.resolve(d), /human-required/);
|
||||
w.resolve(d, 'yes', 'human');
|
||||
const { ticket, by, via } = w.check({ task_ref: TASK, decision: d.id });
|
||||
const { ticket, by, via } = w.b.checkLand(w.caps.cto, { task_ref: TASK, decision: d.id });
|
||||
assert.deepEqual([by, via], ['jason', 'cli']);
|
||||
w.b.recordLand(ticket, w.land);
|
||||
assert.equal(w.merges()[0].body.class, 'cross-role');
|
||||
|
||||
@@ -119,7 +119,7 @@ const TASK = 'vikunja:1/2';
|
||||
|
||||
test('row 302 a run landRelease released answers launcher-released, and claims nothing again', (t) => {
|
||||
const w = setup(t, {
|
||||
pm: { authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
pm: { authority: { withinRole: ['message.send', 'decision.resolve.technical'], crossRole: [] } },
|
||||
coder: { authority: { withinRole: ['git.push.working', 'review.request', 'message.send'], crossRole: [] } },
|
||||
reviewer: { authority: { withinRole: ['review.verdict', 'message.send'], crossRole: [] } },
|
||||
'merge-gate': { authority: { withinRole: [], crossRole: [MERGE] } },
|
||||
|
||||
@@ -43,9 +43,9 @@ async function until(fn, ms = 10000) {
|
||||
}
|
||||
|
||||
const makeBusiness = ({ root, chief = true, instances = null } = {}) => ({
|
||||
id: 'acme', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
id: 'acme', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' },
|
||||
// sage-4097 / mos-1584: the chief's declared read credential; no-chief negative fixture stays absent.
|
||||
roles: { ...(chief ? { chief: { definition: 'chief-of-staff', ...role, credentials: { gitea: giteaReference(join(root, 'secrets', 'agents'), 'acme', 'chief', '2099-01-01') } } } : {}), coder: { definition: 'coder', ...role } },
|
||||
roles: { cto: { definition: 'cto', authority: { withinRole: ['decision.resolve.technical'], crossRole: [] } }, pm: { definition: 'pm', authority: { withinRole: ['decision.resolve.delivery'], crossRole: [] } }, ...(chief ? { chief: { definition: 'chief-of-staff', ...role, credentials: { gitea: giteaReference(join(root, 'secrets', 'agents'), 'acme', 'chief', '2099-01-01') } } } : {}), coder: { definition: 'coder', ...role } },
|
||||
projects: { books: { ...mappingEntry(), user: 'books-bot' } },
|
||||
...(instances ? { launch: { instances } } : {}),
|
||||
});
|
||||
@@ -77,7 +77,7 @@ function world(t, options) {
|
||||
// chief-of-staff instance, fired by boss. The real read grant targets the library project books.
|
||||
function verbDoc(url, root) {
|
||||
return {
|
||||
id: 'acme', human: 'jason', arbiters: { technical: 'boss', delivery: 'boss' },
|
||||
id: 'acme', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {
|
||||
boss: { definition: 'pm', authority: chiefAuthority },
|
||||
// sage-4097 / mos-1584: fixture projection carries the chief's own Gitea store reference.
|
||||
|
||||
@@ -31,8 +31,8 @@ async function world(t, { projects, adapter, claimed = true, now = () => NOW, fi
|
||||
const f = scratchRepo(t, files), dataRoot = join(f.root, 'data');
|
||||
mkdirSync(dataRoot);
|
||||
const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true },
|
||||
projects: projects ?? { books: mappingEntry() }, arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
|
||||
projects: projects ?? { books: mappingEntry() }, arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
|
||||
const context = { business, project: 'books', dataRoot };
|
||||
let mirror = null, state = null, fetches = 0;
|
||||
if (business.projects.books?.kind === 'library') {
|
||||
@@ -222,8 +222,8 @@ test('real socket awaits lookup results and rechecks rejected query arguments',
|
||||
test('runtime actually injects the lookup adapter, without exposing human library.file', { timeout: 30_000 }, async t => {
|
||||
const f = scratchRepo(t, TEXTS), dataRoot = join(f.root, 'runtime'); mkdirSync(dataRoot);
|
||||
const business = { id: 'acme', human: 'jason', plugins: { harness: ['pi'] }, projects: { books: mappingEntry() },
|
||||
arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
|
||||
arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
|
||||
const runtime = await startBroker({ dataRoot, businesses: { acme: business } }); t.after(() => runtime.close());
|
||||
const mirror = library.mirrorPath({ business, project: 'books', dataRoot });
|
||||
git(f.repo, '-c', 'protocol.file.allow=always', 'push', '-q', mirror, 'HEAD:refs/heads/served');
|
||||
|
||||
@@ -21,7 +21,7 @@ function world(t, adapter) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-library-sync-')), store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true },
|
||||
projects: { books: { ...mappingEntry(), user: 'books-bot' } }, arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } };
|
||||
projects: { books: { ...mappingEntry(), user: 'books-bot' } }, arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } };
|
||||
const broker = new Broker({ store, businesses: { acme: business }, library: adapter });
|
||||
const human = broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const agent = broker.bindLaunch({ business: 'acme', role: 'coder', run: 'library-sync-agent', harness: 'pi' });
|
||||
@@ -81,7 +81,7 @@ test('C2-03a runtime: the timer syncs each entry with a user at start and on eac
|
||||
mkdirSync(dataRoot, { mode: 0o700 });
|
||||
mkdirSync(join(configDir, 'secrets', 'acme'), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(join(configDir, 'secrets', 'acme', library.SLOT), TOKEN + '\n', { mode: 0o600 });
|
||||
const business = { id: 'acme', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } },
|
||||
const business = { id: 'acme', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } },
|
||||
projects: { books: { ...mappingEntry(), user: 'books-bot' }, quiet: mappingEntry() } };
|
||||
const calls = [];
|
||||
// A git that succeeds without fetching: the remote-tracking ref stays absent, so each pass fails.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Row 288: broker authority, exact notice and strict Console revert API. Fixture grant rows only.
|
||||
import { refusalGuidance } from '../src/refusals.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
||||
@@ -180,7 +181,7 @@ test('C2-02 socket: a save name nested in proof cannot enlarge an agent envelope
|
||||
socket.on('error', reject); socket.on('connect', () => socket.write(request));
|
||||
socket.on('data', bytes => { result += bytes; if (result.includes('\n')) { socket.destroy(); resolve(JSON.parse(result)); } });
|
||||
});
|
||||
assert.deepEqual(response, { ok: false, error: 'request-too-large' });
|
||||
assert.deepEqual(response, { ok: false, error: 'request-too-large', ...refusalGuidance('request-too-large') });
|
||||
assert.equal(f.seen.length, 0);
|
||||
});
|
||||
|
||||
@@ -234,8 +235,8 @@ test('C2-02 F2/E6 runtime: human saves/reverts use editor.user; no login never u
|
||||
const pushes = [];
|
||||
const command = (kind, url, options) => { if (kind === 'push') pushes.push(options.user); return bare.command(kind, url, options); };
|
||||
const initial = (await bare.state()).lastFetch;
|
||||
const business = { ...bare.context.business, human: 'jason', roles: { coder: { authority: { withinRole: [], crossRole: [] } } },
|
||||
arbiters: { technical: 'coder', delivery: 'coder' } };
|
||||
const business = { ...bare.context.business, human: 'jason', roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } },
|
||||
arbiters: { technical: 'chief', delivery: 'chief' } };
|
||||
const runtime = await startBroker({ dataRoot: bare.context.dataRoot, businesses: { acme: business },
|
||||
librarySync: { configDir, command, intervalMs: 60000 } });
|
||||
try {
|
||||
|
||||
@@ -16,7 +16,7 @@ function world(t, adapter = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-library-')), store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true },
|
||||
projects: { books: mappingEntry() }, arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } };
|
||||
projects: { books: mappingEntry() }, arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } };
|
||||
const broker = new Broker({ store, businesses: { acme: business }, library: adapter });
|
||||
const human = broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const agent = broker.bindLaunch({ business: 'acme', role: 'coder', run: 'library-agent', harness: 'pi' });
|
||||
@@ -68,7 +68,7 @@ test('C2-04 runtime: protectedRoots skips library but retains real code-root cre
|
||||
const f = repo(t), dataRoot = join(f.root, 'data'), token = join(f.root, 'fixture-token');
|
||||
mkdirSync(dataRoot, { mode: 0o700 }); writeFileSync(token, 'made-up-fixture-token-123456', { mode: 0o600 });
|
||||
const business = { id: 'acme', human: 'jason', projects: { books: mappingEntry(), stack: { root: f.repo } },
|
||||
arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] }, credentials: { gitea: { file: token, rotateBy: '2099-01-01' } } } } };
|
||||
arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] }, credentials: { gitea: { file: token, rotateBy: '2099-01-01' } } } } };
|
||||
const runtime = await startBroker({ dataRoot, businesses: { acme: business } });
|
||||
await runtime.close();
|
||||
const mirror = library.mirrorPath({ business, project: 'books', dataRoot });
|
||||
@@ -81,7 +81,7 @@ test('C2-04 runtime: protectedRoots skips library but retains real code-root cre
|
||||
test('C2-04 runtime: unsafe existing mirror refuses boot and never publishes a socket', async t => {
|
||||
const f = repo(t), dataRoot = join(f.root, 'data');
|
||||
mkdirSync(dataRoot, { mode: 0o700 });
|
||||
const business = { id: 'acme', human: 'jason', projects: { books: mappingEntry() }, arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } };
|
||||
const business = { id: 'acme', human: 'jason', projects: { books: mappingEntry() }, arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } };
|
||||
const mirror = join(dataRoot, 'projects/acme/books'); mkdirSync(mirror, { recursive: true });
|
||||
writeFileSync(join(mirror, 'keep'), 'made-up note');
|
||||
await assert.rejects(startBroker({ dataRoot, businesses: { acme: business } }), { code: 'library-git' });
|
||||
@@ -92,7 +92,7 @@ test('C2-04 runtime: unsafe existing mirror refuses boot and never publishes a s
|
||||
test('C2-04 runtime: real library adapters reach the socket and lock waits leave other bus reads responsive', { timeout: 15000 }, async t => {
|
||||
const f = repo(t), dataRoot = join(f.root, 'data');
|
||||
mkdirSync(dataRoot, { mode: 0o700 });
|
||||
const business = { id: 'acme', human: 'jason', projects: { books: mappingEntry() }, arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } };
|
||||
const business = { id: 'acme', human: 'jason', projects: { books: mappingEntry() }, arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } };
|
||||
const runtime = await startBroker({ dataRoot, businesses: { acme: business } });
|
||||
t.after(() => runtime.close());
|
||||
const cap = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
|
||||
@@ -26,7 +26,7 @@ const doc = (extra = {}) => ({
|
||||
retired: ['old'],
|
||||
...extra,
|
||||
});
|
||||
const OTHER = { id: 'other', human: 'jason', arbiters: { technical: 'only-there', delivery: 'only-there' }, roles: { 'only-there': none } };
|
||||
const OTHER = { id: 'other', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, 'only-there': none } };
|
||||
|
||||
function setup(t, { organization = null, testerOn = undefined, processing = undefined } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'lifecycle-verbs-'));
|
||||
@@ -349,7 +349,7 @@ test('row 194 V5 retry: a retired name deletes again only after workspace-rename
|
||||
const { transaction } = f.b.lifecycleDelete({ business: 'demo', request });
|
||||
f.done(request, { transaction, refusal: 'workspace-rename-failed' });
|
||||
// The bus reloads after the commit, as the runtime does.
|
||||
const next = doc({ retired: ['old', 'pm'], arbiters: { technical: 'coder', delivery: 'chief' } });
|
||||
const next = doc({ retired: ['old', 'pm'], arbiters: { technical: 'chief', delivery: 'chief' } });
|
||||
delete next.roles.pm;
|
||||
f.b.reloadBusiness('demo', next);
|
||||
const writes = w.calls.length;
|
||||
|
||||
@@ -13,8 +13,8 @@ import { Broker } from '../src/broker.mjs';
|
||||
|
||||
const shipped = name => JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
|
||||
const businesses = () => ({ demo: {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
coder: { definition: 'coder', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
},
|
||||
|
||||
@@ -20,8 +20,8 @@ async function setup(t, plugins, limit = 0) {
|
||||
writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'),
|
||||
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } }, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } },
|
||||
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } }, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } },
|
||||
organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
return { bus, human: bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) };
|
||||
|
||||
@@ -8,7 +8,7 @@ import {Broker} from '../src/broker.mjs';
|
||||
import {Store} from '../src/store.mjs';
|
||||
test('limits binds actor from human proof and rejects role, reader, a tester set and forged actor',t=>{
|
||||
const root=mkdtempSync(join(tmpdir(),'mosaic-limits-bus-')),store=new Store(root);t.after(()=>{store.close();rmSync(root,{recursive:true,force:true});});const calls=[];
|
||||
const b=new Broker({store,businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},vars:{'bus.tester':true},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:(...args)=>{calls.push(args);return {revision:'b'.repeat(64)};}});
|
||||
const b=new Broker({store,businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},vars:{'bus.tester':true},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:(...args)=>{calls.push(args);return {revision:'b'.repeat(64)};}});
|
||||
const human=b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true}),role=b.bindLaunch({business:'demo',role:'coder',run:'coder-run',harness:'pi',address:'coder-run'}),reader=b.bindReader({business:'demo'}),tester=b.bindTester({business:'demo'}),patch={revision:'a'.repeat(64),limits:{total:0}};
|
||||
for(const cap of [role,reader])for(const verb of ['limits','limits.set'])assert.throws(()=>b.request(cap,{verb,args:verb==='limits'?{}:patch}),/human-required/);
|
||||
// Row 175 (#1673): the tester reads limits; limits.set stays the human's.
|
||||
@@ -19,7 +19,7 @@ test('limits binds actor from human proof and rejects role, reader, a tester set
|
||||
test('runtime wires verified human settings to the actual shared file writer and actor receipt',async t=>{
|
||||
const {startBroker}=await import('../src/runtime.mjs');const {businessDoc,rolesCopy,systemFor,writeJson}=await import('../../organization/tests/helpers.mjs');const {readFileSync}=await import('node:fs');
|
||||
const root=mkdtempSync(join(tmpdir(),'mosaic-limits-runtime-')),rolesDir=rolesCopy(root),doc=businessDoc(root,'demo');writeJson(join(root,'businesses/demo.json'),doc);mkdirSync(join(root,'data'),{mode:0o700});
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)}});
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)}});
|
||||
t.after(async()=>{await bus.close();rmSync(root,{recursive:true,force:true});});const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
const view=bus.broker.request(human,{verb:'limits'});const done=bus.broker.request(human,{verb:'limits.set',args:{revision:view.revision,limits:{total:0}}});assert.equal(done.limits.total,0);assert.equal(JSON.parse(readFileSync(join(root,'businesses/demo.json'),'utf8')).launch.limits.total,0);const receipt=JSON.parse(readFileSync(join(root,'businesses/.history/demo/changes.jsonl'),'utf8').trim().split('\n').at(-1));assert.equal(receipt.actor,'jason');assert.equal(receipt.state,'committed');
|
||||
});
|
||||
@@ -28,7 +28,7 @@ test('uncertain organization receipts leave bus startup and reads alone but refu
|
||||
const root=mkdtempSync(join(tmpdir(),'mosaic-limits-recovery-')),rolesDir=rolesCopy(root),doc=businessDoc(root,'demo'),file=writeJson(join(root,'businesses/demo.json'),doc);mkdirSync(join(root,'data'),{mode:0o700});
|
||||
const writer=organizationWriter({dir:root,rolesDir}),original=readFileSync(file,'utf8'),revision=writer.read('demo').revision,next=structuredClone(doc);next.launch.limits.total=7;
|
||||
const interrupted=organizationWriter({dir:root,rolesDir,barrier:()=>{throw Error('fixture interruption');}});assert.throws(()=>interrupted.write('demo',next,{revision,actor:'jason'}));const external=structuredClone(doc);external.launch.limits.total=12;writeJson(file,external);const bytes=readFileSync(file,'utf8');
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)}});t.after(async()=>{await bus.close();rmSync(root,{recursive:true,force:true});});
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)}});t.after(async()=>{await bus.close();rmSync(root,{recursive:true,force:true});});
|
||||
const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});const view=bus.broker.request(human,{verb:'limits'});assert.equal(view.limits.total,12);assert.throws(()=>bus.broker.request(human,{verb:'limits.set',args:{revision:view.revision,limits:{total:1}}}),/organization-uncertain/);assert.equal(readFileSync(file,'utf8'),bytes);
|
||||
// The operator reconciles the fixture to the recorded old whole document.
|
||||
writeJson(file,original);const reconciled=bus.broker.request(human,{verb:'limits'});const result=bus.broker.request(human,{verb:'limits.set',args:{revision:reconciled.revision,limits:{total:1}}});assert.equal(result.limits.total,1);
|
||||
|
||||
@@ -15,7 +15,7 @@ function fixture(t, cross = false) {
|
||||
arbiters: { technical: 'cto', delivery: 'pm' },
|
||||
roles: {
|
||||
pm: role(cross ? [] : ['message.send'], cross ? ['message.send'] : []),
|
||||
cto: role(),
|
||||
cto: role(['decision.resolve.technical']),
|
||||
coder: role(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -46,7 +46,7 @@ test('launch admission refreshes selection and refuses a removed model and incom
|
||||
writeJson(join(root,'businesses/demo.json'),doc); mkdirSync(join(root,'data'),{mode:0o700});
|
||||
let present=true, reads=0;
|
||||
const service={invalidate(){},read:async(_,{fresh})=>{assert.equal(fresh,true);reads++;return {status:'ready',models:present?[{module:'cliproxy',upstream:'zai',model:'fixture-id',available:true,harnesses:['pi']}]:[]};}};
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue:service});t.after(()=>bus.close());
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue:service});t.after(()=>bus.close());
|
||||
const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
const revision=bus.broker.request(human,{verb:'limits'}).revision;
|
||||
const {checkedTransport}=await import('../../harness/src/bundle.mjs');
|
||||
@@ -78,7 +78,7 @@ test('a different enabled fixture provider supplies legacy mapping and admission
|
||||
const makeProviderLoader=()=>({load:async()=>({refused:[]}),snapshot:()=>[{name:'second-fixture',module}]});
|
||||
const offered={key:'fixture-model',module:'second-fixture',upstream:'fixture',provider:'fixture',model:'fixture-model',label:'Fixture',available:true,reason:null,harnesses:['pi']};
|
||||
const modelCatalogue={read:async()=>({status:'ready',models:[offered],notices:[]}),invalidate(){}};
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue,makeProviderLoader});t.after(()=>bus.close());
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue,makeProviderLoader});t.after(()=>bus.close());
|
||||
const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});const view=await bus.broker.request(human,{verb:'routes'});
|
||||
assert.equal(view.catalogue.legacy['fixture-legacy'].module,'second-fixture');
|
||||
const answer=await bus.modelAdmission({business:'demo',instance:'coder',modelKey:'fixture-legacy',harness:'pi',revision:view.revision,entry:doc.launch.models['fixture-legacy'],provider:'fixture',model:'fixture-model',account:null,route:'primary',transport});
|
||||
|
||||
@@ -27,7 +27,7 @@ export async function ownershipFixture(t,{launches=true,listed=true,keep=false,r
|
||||
t.after(async()=>{const failures=[];for(const g of gates)g.release();try{await waitFor(()=>active.size===0,'owned operations did not settle',5000);}catch{failures.push('owned-operation-close');}for(const close of [...closers].reverse()){try{await close();}catch{failures.push('owned-close');}}try{server?.closeAllConnections();if(server?.listening)await new Promise((resolve,reject)=>server.close(e=>e?reject(e):resolve()));}catch{failures.push('owned-server-close');}try{checkHistory({logs,proxyReceipts,trace});}catch{failures.push('value-free-receipts');}try{checkHistory({launch:launchLog()});}catch{failures.push('owned-launch-log-guard');}if(stderr)process.stderr.write=stderr;try{fs.rmSync(root,{recursive:true,force:true});}catch{failures.push('owned-root-close');}if(failures.length)throw new AggregateError(failures.map(code=>Error(code)),'owned managed fixture cleanup failed');});
|
||||
const f=fixture(root,'acme',doc=>{doc.plugins={...doc.plugins,harness:['pi']};for(const role of Object.values(doc.roles))role.vars={...role.vars,harness:'pi'};doc.roles.chief.vars.model='claude-opus-5-5';doc.roles.aide={definition:'aide',vars:{harness:'pi',model:'claude-opus-5-5',thinking:'high'}};if(listed)doc.launch.instances.push('aide');if(keep)doc.launch.keep=['aide'];if(retired)doc.retired=['alpha'];if(noHire)doc.launch.noHire=['aide'];return doc;});
|
||||
// The established scratch aide pattern only; repository roles/policy stay untouched.
|
||||
for(const name of ['aide','helper']){fs.writeFileSync(join(f.roles,name+'.md'),'# Scratch aide\n');writeJson(join(f.roles,name+'.json'),{roleVersion:2,name,title:'Aide',contract:name+'.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});}
|
||||
for(const name of ['aide','helper']){fs.writeFileSync(join(f.roles,name+'.md'),'# Scratch aide\n\n## Authority\n');writeJson(join(f.roles,name+'.json'),{roleVersion:2,name,title:'Aide',contract:name+'.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});}
|
||||
const chiefFile=join(f.roles,'chief-of-staff.json');writeJson(chiefFile,{...JSON.parse(fs.readFileSync(chiefFile,'utf8')),hireable:['aide','helper']});
|
||||
const env={PATH:process.env.PATH,HOME:process.env.HOME,TMPDIR:process.env.TMPDIR,MOSAIC_CONFIG:f.config,MOSAIC_ROLES_DIR:f.roles};
|
||||
const configDir=join(root,'config'),prefix=`msk-${id(configDir)}-`,sentinel=prefix+'sentinel-'+randomBytes(32).toString('base64url');history.add(sentinel);
|
||||
|
||||
@@ -176,7 +176,7 @@ test('proxy-stop-failed: old proxy key and retiring record survive a failed grac
|
||||
async function bootBusiness(f, binding = {}) {
|
||||
const rolesDir = rolesCopy(f.root), system = systemFor(f.root), doc = businessDoc(f.root, 'demo');
|
||||
// Aide is needed because shipped chief hireable:[] and security-reviewer has review.verdict outside HIRE_ALLOWED.
|
||||
fs.writeFileSync(join(rolesDir, 'aide.md'), '# Aide\n');
|
||||
fs.writeFileSync(join(rolesDir, 'aide.md'), '# Aide\n\n## Authority\n');
|
||||
writeJson(join(rolesDir, 'aide.json'), {
|
||||
roleVersion: 2, name: 'aide', title: 'Aide', contract: 'aide.md', tools: ['read'], network: 'api-only',
|
||||
authority: { withinRole: ['message.send'], crossRole: [] }, credentials: [],
|
||||
|
||||
@@ -15,7 +15,7 @@ async function hireFixture(t,{revoking=false}={}){
|
||||
if(revoking){const r=old.credentials.modelRecord('demo/orphan');old.credentials.writeModel('demo/orphan',{...r,state:'revoking',revokingAt:'2026-10-25T00:00:00.000Z'});}old.credentials.close();
|
||||
const rolesDir=rolesCopy(f.root),system=systemFor(f.root),doc=businessDoc(f.root,'demo');
|
||||
// Scratch aide: shipped chief hireable:[] and review.verdict is outside HIRE_ALLOWED.
|
||||
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});
|
||||
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n\n## Authority\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});
|
||||
const chiefFile=join(rolesDir,'chief-of-staff.json');writeJson(chiefFile,{...JSON.parse(fs.readFileSync(chiefFile,'utf8')),hireable:['aide']});doc.plugins={...doc.plugins,harness:['pi']};
|
||||
const file=writeJson(join(f.configDir,'businesses','demo.json'),doc);fs.writeFileSync(join(f.configDir,'secrets','proxy-management.key'),'fx651-manage',{mode:0o600});
|
||||
const business=loadBusiness('demo',{dir:f.configDir,rolesDir}),resolved={};for(const instance of Object.keys(business.roles))resolved[instance]=resolveInstance({system,business,instance});
|
||||
|
||||
@@ -22,7 +22,7 @@ export async function pendingFixture(t,{twoBusinesses=false}={}){
|
||||
const configDir=join(root,'config'),dataRoot=join(root,'data'),rolesDir=rolesCopy(root),system=systemFor(root);fs.mkdirSync(dataRoot,{mode:0o700});
|
||||
const doc=businessDoc(root,'demo');doc.plugins={...doc.plugins,harness:['pi']};
|
||||
// Existing row331 scratch aide pattern only; repository role authority unchanged.
|
||||
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});const chiefFile=join(rolesDir,'chief-of-staff.json');writeJson(chiefFile,{...JSON.parse(fs.readFileSync(chiefFile,'utf8')),hireable:['aide']});
|
||||
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n\n## Authority\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});const chiefFile=join(rolesDir,'chief-of-staff.json');writeJson(chiefFile,{...JSON.parse(fs.readFileSync(chiefFile,'utf8')),hireable:['aide']});
|
||||
const ids=twoBusinesses?['demo','other']:['demo'],files={},businesses={};for(const name of ids){const document={...structuredClone(doc),id:name};files[name]=writeJson(join(configDir,'businesses',name+'.json'),document);const business=loadBusiness(name,{dir:configDir,rolesDir}),resolved={};for(const instance of Object.keys(business.roles))resolved[instance]=resolveInstance({system,business,instance});businesses[name]=busOrganization(business,resolved);}
|
||||
fs.mkdirSync(join(configDir,'secrets'),{recursive:true,mode:0o700});fs.writeFileSync(join(configDir,'secrets','proxy-management.key'),'fx654-manage',{mode:0o600});fs.mkdirSync(join(dataRoot,'bus-host'),{recursive:true,mode:0o700});writeJson(sessionsFile(dataRoot),{sessionsVersion:SESSIONS_VERSION,sessions:[]});
|
||||
const sentinel=`msk-${hash(configDir).slice(0,12)}-sentinel-${randomBytes(32).toString('base64url')}`;state.keys.push(sentinel);history.add(sentinel);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { join } from 'node:path';
|
||||
import { Broker } from '../src/broker.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
const role={authority:{withinRole:['message.send'],crossRole:[]}};
|
||||
function fixture(t,proxyInit){const store=new Store(mkdtempSync(join(tmpdir(),'p1-bus-')));t.after(()=>store.close());const b=new Broker({store,proxyInit,businesses:{demo:{id:'demo',human:'owner',arbiters:{technical:'coder',delivery:'coder'},vars:{'bus.tester':true},roles:{coder:role}}}});return {store,b,human:b.bindHuman({business:'demo',human:'owner',via:'cli',outsideAgent:true}),tester:b.bindTester({business:'demo'}),reader:b.bindReader({business:'demo'}),agent:b.bindLaunch({business:'demo',role:'coder',run:'p1-run',harness:'pi'})};}
|
||||
function fixture(t,proxyInit){const store=new Store(mkdtempSync(join(tmpdir(),'p1-bus-')));t.after(()=>store.close());const b=new Broker({store,proxyInit,businesses:{demo:{id:'demo',human:'owner',arbiters:{technical:'chief',delivery:'chief'},vars:{'bus.tester':true},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:role}}}});return {store,b,human:b.bindHuman({business:'demo',human:'owner',via:'cli',outsideAgent:true}),tester:b.bindTester({business:'demo'}),reader:b.bindReader({business:'demo'}),agent:b.bindLaunch({business:'demo',role:'coder',run:'p1-run',harness:'pi'})};}
|
||||
test('proxy.init only accepts path-bound outside-agent human; refuses agent/tester/reader',async t=>{const calls=[];const f=fixture(t,async a=>{calls.push(a);return {port:a.port,binary:'/fixture'};});for(const cap of [f.tester,f.agent])assert.throws(()=>f.b.request(cap,{verb:'proxy.init',args:{key:'p1-management'}}),e=>e.code==='human-required');assert.throws(()=>f.b.request(f.reader,{verb:'proxy.init',args:{key:'p1-management'}}),e=>e.code==='read-only');assert.equal(calls.length,0);assert.deepEqual(await f.b.request(f.human,{verb:'proxy.init',args:{key:'p1-management',port:18432}}),{port:18432,binary:'/fixture'});assert.equal(JSON.stringify(f.store.all('SELECT body FROM events')).includes('p1-management'),false);});
|
||||
for(const [code,args]of [['proxy-key-invalid',{key:'a\nb'}],['proxy-config-invalid',{key:'p1-management',port:8317}],['invalid-request',{key:'p1-management',binary:'/forged'}]])test(`proxy.init ${code}`,t=>{const f=fixture(t,()=>{throw Error('must not call');});assert.throws(()=>f.b.request(f.human,{verb:'proxy.init',args}),e=>e.code===code);});
|
||||
test('proxy.init not-configured',t=>{const f=fixture(t,null);assert.throws(()=>f.b.request(f.human,{verb:'proxy.init',args:{key:'p1-management'}}),e=>e.code==='not-configured');});
|
||||
|
||||
@@ -51,7 +51,7 @@ export async function revocationFixture(t,{runtime=false,onRoot=()=>{},missingBo
|
||||
let boot,writer,chief,human,busBoots=0;
|
||||
if(runtime){
|
||||
const rolesDir=rolesCopy(root),system=systemFor(root),doc=businessDoc(root,'demo');doc.plugins={...doc.plugins,harness:['pi']};doc.roles.aide={definition:'aide',vars:{harness:'pi',model:'claude-opus-5-5',thinking:'high'}};doc.launch.instances.push('aide');
|
||||
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});writeJson(join(configDir,'businesses','demo.json'),doc);
|
||||
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n\n## Authority\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});writeJson(join(configDir,'businesses','demo.json'),doc);
|
||||
const business=loadBusiness('demo',{dir:configDir,rolesDir}),resolved={};for(const name of Object.keys(business.roles))resolved[name]=resolveInstance({system,business,instance:name});
|
||||
boot={dataRoot,businesses:{demo:busOrganization(business,resolved)},organization:{dir:configDir,rolesDir,system},proxy:{port,binary:'/fixture-unused'}};writer=organizationWriter({dir:configDir,rolesDir});
|
||||
stderr=process.stderr.write;process.stderr.write=function(chunk,...args){if(String(chunk).startsWith('model-proxy: ')){receipts.push(String(chunk));args.find(a=>typeof a==='function')?.();return true;}return stderr.call(this,chunk,...args);};
|
||||
|
||||
@@ -24,7 +24,7 @@ const demo = () => ({
|
||||
demo: {
|
||||
id: 'demo',
|
||||
human: 'jason',
|
||||
arbiters: { technical: 'lead', delivery: 'chief' },
|
||||
arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles,
|
||||
definitions: { reviewer: {} },
|
||||
vars: { 'bus.tester': true },
|
||||
|
||||
@@ -9,7 +9,7 @@ import { createLimits, modelUsageKey } from '../src/limits.mjs';
|
||||
const key='012345abcdef';
|
||||
test('P5 broker preserves original plugin subject and derives internal run context from authenticated native caller',async t=>{
|
||||
const root=mkdtempSync(join(tmpdir(),'p5-context-')),store=new Store(root),asked=[];t.after(()=>{store.close();rmSync(root,{recursive:true,force:true});});
|
||||
const b=new Broker({store,businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},
|
||||
const b=new Broker({store,businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},
|
||||
limits:{mustStop:async(...args)=>{asked.push(args);return {stop:false};}},usage:{collect:async()=>({recorded:true}),view:()=>({})}});
|
||||
const human=b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
const cap=b.bindLaunch({business:'demo',role:'coder',run:'native-run',harness:'pi',provider:'fixture',model:'fake'});b.request(cap,{verb:'role.claim'});
|
||||
|
||||
@@ -30,7 +30,7 @@ test('P5 real runtime binds stack usage to issued key, daily stops at stack coun
|
||||
});
|
||||
await new Promise(r=>server.listen(0,'127.0.0.1',r));let bus;
|
||||
t.after(async()=>{await bus?.close();await new Promise(r=>server.close(r));rmSync(root,{recursive:true,force:true});});
|
||||
bus=await startBroker({dataRoot:data,businesses:{demo:{id:'demo',human:'jason',plugins:doc.plugins,arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{definition:'coder',authority:{withinRole:[],crossRole:[]}}}}},
|
||||
bus=await startBroker({dataRoot:data,businesses:{demo:{id:'demo',human:'jason',plugins:doc.plugins,arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{definition:'coder',authority:{withinRole:[],crossRole:[]}}}}},
|
||||
organization:{dir:config,rolesDir,system:systemFor(config)},proxy:{port:server.address().port,binary:'/fixture-unused'}});
|
||||
assert.equal(calls.some(p=>p.includes('usage/queue')),false);
|
||||
const model=await bus.modelPreflight({business:'demo',role:'coder'}),keyId=hash(model.key);
|
||||
@@ -77,7 +77,7 @@ test('R4 real runtime: a quota spent on A and moved to B with no reset still ref
|
||||
});
|
||||
await new Promise(r=>server.listen(0,'127.0.0.1',r));let bus;
|
||||
t.after(async()=>{await bus?.close();await new Promise(r=>server.close(r));rmSync(root,{recursive:true,force:true});});
|
||||
bus=await startBroker({dataRoot:data,businesses:{demo:{id:'demo',human:'jason',plugins:doc.plugins,arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{definition:'coder',authority:{withinRole:[],crossRole:[]}}}}},
|
||||
bus=await startBroker({dataRoot:data,businesses:{demo:{id:'demo',human:'jason',plugins:doc.plugins,arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{definition:'coder',authority:{withinRole:[],crossRole:[]}}}}},
|
||||
organization:{dir:config,rolesDir,system:systemFor(config)},proxy:{port:server.address().port,binary:'/fixture-unused'}});
|
||||
const stored=()=>JSON.parse(readFileSync(join(config,'businesses/demo.json'),'utf8')).launch.limits.tokens.keys;
|
||||
const may=async()=>(await bus.mayLaunch({business:'demo',instance:'coder',provider:'fixture',model:'fake'})).allow;
|
||||
|
||||
@@ -64,7 +64,7 @@ export async function telemetryFixture(t, { foreign = false, hold = false, waitM
|
||||
});
|
||||
await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));
|
||||
let bus=null,startPromise=null,closePromise=null;
|
||||
const start=()=>startPromise??=(startBroker({dataRoot:data,...(orgMode ? {instanceRoot:root} : {}),businesses:{demo:{id:'demo',human:'jason',plugins:doc.plugins,arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{definition:'coder',authority:{withinRole:[],crossRole:[]}}}}},
|
||||
const start=()=>startPromise??=(startBroker({dataRoot:data,...(orgMode ? {instanceRoot:root} : {}),businesses:{demo:{id:'demo',human:'jason',plugins:doc.plugins,arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{definition:'coder',authority:{withinRole:[],crossRole:[]}}}}},
|
||||
organization:{dir:config,rolesDir,system:systemFor(config)},proxy:{port:server.address().port,binary:'/fixture-unused'}}).then(value=>(bus=value)));
|
||||
const release=(fail=false)=>{state.hold=false;for(const res of state.held.splice(0)){if(res.destroyed)continue;if(fail){res.writeHead(503);res.end('{}');}else answerQueue(res);}};
|
||||
const close=()=>bus?(closePromise??=bus.close()):Promise.resolve();
|
||||
|
||||
@@ -37,7 +37,7 @@ test('runtime: the switch changes the organization file, the receipt carries the
|
||||
const rolesDir = rolesCopy(root);
|
||||
writeJson(join(root, 'businesses/demo.json'), businessDoc(root, 'demo'));
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const file = join(root, 'businesses/demo.json');
|
||||
@@ -72,7 +72,7 @@ test('vikunja:19/309 through the broker, a patch switches the kinds it names and
|
||||
const rolesDir = rolesCopy(root);
|
||||
writeJson(join(root, 'businesses/demo.json'), { ...businessDoc(root, 'demo'), plugins: { 'usage-source': ['meter'] } });
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const log = () => readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
|
||||
@@ -97,7 +97,7 @@ test('plugins read advertises loadable harnesses even when switched off', async
|
||||
const root=mkdtempSync(join(tmpdir(),'plugins-available-'));t.after(()=>rmSync(root,{recursive:true,force:true}));
|
||||
const rolesDir=rolesCopy(root);const doc=businessDoc(root,'demo');doc.plugins={harness:['claude-code']};
|
||||
writeJson(join(root,'businesses/demo.json'),doc);mkdirSync(join(root,'data'),{mode:0o700});
|
||||
const host=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)}});t.after(()=>host.close());
|
||||
const host=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)}});t.after(()=>host.close());
|
||||
const human=host.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
const view=host.broker.request(human,{verb:'plugins'});
|
||||
assert.deepEqual(view.plugins.harness,['claude-code']);assert.ok(view.available.harness.includes('pi'));assert.ok(view.available.harness.includes('claude-code'));
|
||||
|
||||
@@ -10,8 +10,8 @@ import { Client } from '../src/client.mjs';
|
||||
import { registerRuntime, updateRuntime, processIdentity } from '../../processing-control/src/runtimes.mjs';
|
||||
import { readControl } from '../../processing-control/src/state.mjs';
|
||||
|
||||
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { authority: { withinRole: ['message.send'], crossRole: [] } } } } };
|
||||
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: ['message.send'], crossRole: [] } } } } };
|
||||
async function broker(root) {
|
||||
const r = await startBroker({ dataRoot: root, businesses, readers: ['demo'] });
|
||||
const cap = r.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
|
||||
@@ -13,7 +13,7 @@ const state = await import('../../processing-control/src/state.mjs').catch(e =>
|
||||
throw e;
|
||||
});
|
||||
const businesses = { demo: { id: 'demo', human: 'jason', vars: { 'bus.tester': true },
|
||||
arbiters: { technical: 'coder', delivery: 'coder' }, roles: {
|
||||
arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
coder: { definition: 'coder', authority: { withinRole: ['message.send', 'record.write'], crossRole: [] } },
|
||||
} } };
|
||||
function fixture(t, extra = {}) {
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Broker, TESTER_VERBS, USER_VERBS, VIEWER_VERBS } from '../src/broker.mj
|
||||
const shipped = (name) => JSON.parse(readFileSync(new URL(`../../../roles/${name}.json`, import.meta.url), 'utf8'));
|
||||
const AT = '2026-11-05T00:00:00.000Z';
|
||||
const business = {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'continuity', delivery: 'continuity' },
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {
|
||||
continuity: { definition: 'continuity', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
|
||||
@@ -10,7 +10,7 @@ function fixture(t, project, secretCheck = () => {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'project-await-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: {
|
||||
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
coder: { definition: 'coder', authority: { withinRole: ['record.write'], crossRole: [] } },
|
||||
} };
|
||||
const broker = new Broker({ store, businesses: { demo: business }, project, secretCheck });
|
||||
|
||||
@@ -5,6 +5,7 @@ import { join } from 'node:path';
|
||||
import { fixture, git } from './spec-fixtures.mjs';
|
||||
import { serve } from '../src/server.mjs';
|
||||
import { Client } from '../src/client.mjs';
|
||||
import { refusalGuidance } from '../src/refusals.mjs';
|
||||
import { PROJECT_FILE_VERBS } from '../src/broker.mjs';
|
||||
// Row 148 (#1642): the human-only verbs behind the Console's spec and docs editor.
|
||||
const refusals = f => f.store.all("SELECT body FROM events WHERE kind='action.refused'").map(r => JSON.parse(r.body).code);
|
||||
@@ -109,11 +110,11 @@ test('a request that names the save verb first and another verb last is refused
|
||||
const f = await socketFixture(t);
|
||||
const pad = 'x'.repeat(100000);
|
||||
const sneaky = `{"human":{"fixture":true},"verb":"project.file.save","verb":"agents","args":{"pad":"${pad}"}}\n`;
|
||||
assert.deepEqual(await raw(f.path, sneaky), { ok: false, error: 'request-too-large' });
|
||||
assert.deepEqual(await raw(f.path, sneaky), { ok: false, error: 'request-too-large', ...refusalGuidance('request-too-large') });
|
||||
const unmarked = await raw(f.path, JSON.stringify({ human: { fixture: true }, verb: 'agents', args: { pad } }) + '\n');
|
||||
assert.deepEqual(unmarked, { ok: false, error: 'request-too-large' });
|
||||
assert.deepEqual(unmarked, { ok: false, error: 'request-too-large', ...refusalGuidance('request-too-large') });
|
||||
const over = await raw(f.path, JSON.stringify({ human: { fixture: true }, verb: 'project.file.save', args: { pad: 'x'.repeat(1024 * 1024 + 80000) } }) + '\n');
|
||||
assert.deepEqual(over, { ok: false, error: 'request-too-large' });
|
||||
assert.deepEqual(over, { ok: false, error: 'request-too-large', ...refusalGuidance('request-too-large') });
|
||||
});
|
||||
|
||||
test('two saves racing for one repository make one commit', async t => {
|
||||
|
||||
@@ -13,6 +13,8 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
|
||||
const source = readFileSync(new URL('../src/broker.mjs', import.meta.url), 'utf8');
|
||||
assert.deepEqual([...source.matchAll(/^import .*;$/gm)].map(m => m[0]), [
|
||||
// Rows330/331: pure verb/refusal vocabulary, never the host management implementation.
|
||||
"import { ownRails } from '../../organization/src/rails.mjs';",
|
||||
"import { refusalGuidance } from './refusals.mjs';",
|
||||
"import { PROXY_PAGE_VERBS, PROXY_PAGE_CODES } from './proxy-page-vocabulary.mjs';",
|
||||
// Row 210 (#1707): createHash gives host.run events the argv, stdin and output sha256 values.
|
||||
"import { randomUUID, randomBytes, createHash } from 'node:crypto';",
|
||||
@@ -64,7 +66,7 @@ test('real startBroker injects the project adapter for socket reads and bound-au
|
||||
t.after(async () => { await runtime?.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
// Row 65: the bound run's harness is switched on.
|
||||
const business = { id: 'demo', human: 'jason', plugins: { harness: ['pi'] }, projects: { stack: { root: join(root, 'code') } },
|
||||
arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: {
|
||||
arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: {
|
||||
definition: 'coder', authority: { withinRole: ['record.write'], crossRole: [] },
|
||||
} } };
|
||||
const initialized = await initProject({ business, project: 'stack', dataRoot: root });
|
||||
|
||||
@@ -15,7 +15,7 @@ test('role must not materialize a new catalogue key by submitting legacy entry s
|
||||
const offered={key:'zai-fixture-new',module:'cliproxy',upstream:'zai',provider:'zai',model:'fixture-new',label:'Fixture',available:true,reason:null,harnesses:['pi']};
|
||||
let status='ready';
|
||||
const modelCatalogue={invalidate(){},read:async()=>({status,models:[offered],notices:[]})};
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}},chief:{definition:'chief-of-staff',authority}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue});t.after(()=>bus.close());
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{coder:{authority:{withinRole:[],crossRole:[]}},chief:{definition:'chief-of-staff',authority}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue});t.after(()=>bus.close());
|
||||
const db=new DatabaseSync(join(root,'data/bus/bus.sqlite'),{readOnly:true});t.after(()=>db.close());
|
||||
const allowed=()=>db.prepare("SELECT count(*) AS n FROM events WHERE kind='action.allowed' AND json_extract(body,'$.action')='organization.change'").get().n;
|
||||
const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
|
||||
@@ -20,7 +20,7 @@ async function fixture(t, options={}) {
|
||||
if(options.fallback)Object.assign(doc.roles.coder.vars,{'fallback.harness':'pi','fallback.model':'existing-alias','fallback.thinking':'low'});
|
||||
writeJson(file,doc);fs.mkdirSync(join(root,'data'),{mode:0o700});
|
||||
const modelCatalogue={invalidate(){},read:async()=>({status:'ready',models:[offer],notices:[]})};
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{...Object.fromEntries(Object.keys(doc.roles).map(key=>[key,{authority:{withinRole:[],crossRole:[]}}])),chief:{definition:'chief-of-staff',authority}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue});t.after(()=>bus.close());
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{...Object.fromEntries(Object.keys(doc.roles).map(key=>[key,{authority:{withinRole:[],crossRole:[]}}])),chief:{definition:'chief-of-staff',authority}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue});t.after(()=>bus.close());
|
||||
const db=new DatabaseSync(join(root,'data/bus/bus.sqlite'),{readOnly:true});t.after(()=>db.close());
|
||||
const count=()=>db.prepare("SELECT count(*) AS n FROM events WHERE kind='action.allowed' AND json_extract(body,'$.action')='organization.change'").get().n;
|
||||
const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
|
||||
@@ -18,8 +18,8 @@ function setup(t, proxyPage = async () => ({ ok: true })) {
|
||||
access: () => ({ accessVersion: 1, revision: 1, users: [], grants: [
|
||||
{ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['coder'], level: 'collaborator' },
|
||||
] }),
|
||||
businesses: { demo: { id: 'demo', human: 'owner', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
vars: { 'bus.tester': true }, roles: { coder: { definition: 'coder', authority: { withinRole: ['message.send'], crossRole: [] } } } } },
|
||||
businesses: { demo: { id: 'demo', human: 'owner', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
vars: { 'bus.tester': true }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: ['message.send'], crossRole: [] } } } } },
|
||||
});
|
||||
const caps = {
|
||||
human: broker.bindHuman({ business: 'demo', human: 'owner', via: 'cli', outsideAgent: true }),
|
||||
|
||||
@@ -24,9 +24,9 @@ function businesses(upstream = 'https://git.example/mosaic/stack') {
|
||||
demo: {
|
||||
id: 'demo',
|
||||
human: 'jason',
|
||||
arbiters: { technical: 'reviewer', delivery: 'reviewer' },
|
||||
arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
projects: { stack: { root: '/nonexistent', remote: upstream }, books: { root: '/nonexistent-books' } },
|
||||
roles: {
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
coder: { authority: pusher, identity: identity('acme-coder'), publish: ['stack'] },
|
||||
second: { authority: pusher, identity: identity('acme-second'), publish: ['stack'] },
|
||||
plain: { authority: pusher },
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { connect } from 'node:net';
|
||||
import { PROXY_PAGE_CODES } from '../src/proxy-page-vocabulary.mjs';
|
||||
import { Broker, BusError } from '../src/broker.mjs';
|
||||
import { REFUSALS, refusalGuidance } from '../src/refusals.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { busOrganization } from '../src/organization-context.mjs';
|
||||
import { serve } from '../src/server.mjs';
|
||||
import { Client } from '../src/client.mjs';
|
||||
import { resolveInstance, validateBusinessDocument } from '../../organization/src/index.mjs';
|
||||
import { businessDoc, REPO_ROLES, systemFor } from '../../organization/tests/helpers.mjs';
|
||||
import { railsText } from '../../organization/src/rails.mjs';
|
||||
import { GUARD_CODES } from '../../library/src/reply-protocol.mjs';
|
||||
const SECRET = 'sk-test-NOT-A-SECRET-1234';
|
||||
function fixture(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'rails-'));
|
||||
const business = validateBusinessDocument(businessDoc(root), join(root, 'acme.json'), { rolesDir: REPO_ROLES });
|
||||
const resolve = b => Object.fromEntries(Object.keys(b.roles).map(instance => [instance, resolveInstance({ system: systemFor(root), business: b, instance })]));
|
||||
writeFileSync(business.roles.coder.credentials.gitea.file, SECRET);
|
||||
const resolved = resolve(business), store = new Store(root);
|
||||
const broker = new Broker({ store, businesses: { acme: busOrganization(business, resolved) } });
|
||||
const cap = broker.bindLaunch({ business: 'acme', role: 'coder', run: 'coder-run', harness: 'pi' });
|
||||
broker.request(cap, { verb: 'role.claim' });
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
return { root, business, resolved, resolve, broker, store, cap };
|
||||
}
|
||||
|
||||
test('my_rails equals the resolved prompt content, reads live narrowing, and writes/raises nothing', t => {
|
||||
const f = fixture(t), call = args => f.broker.request(f.cap, { verb: 'my_rails', args });
|
||||
const before = f.store.get('SELECT count(*) n FROM events').n;
|
||||
assert.deepEqual(call({}), f.resolved.coder.rails);
|
||||
const result = call({}); result.actions.length = 0;
|
||||
assert.notEqual(call({}).actions.length, 0, 'returned rails do not mutate policy');
|
||||
const business = structuredClone(f.business);
|
||||
business.roles.coder.vars['limits.authority'] = ['message.send'];
|
||||
const narrowed = f.resolve(business);
|
||||
f.broker.reloadBusiness('acme', busOrganization(business, narrowed));
|
||||
assert.deepEqual(call({}), narrowed.coder.rails);
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM events').n, before);
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, 0);
|
||||
});
|
||||
|
||||
test('my_rails cannot select another identity or expose credential refs and file/call values', t => {
|
||||
const f = fixture(t);
|
||||
for (const args of [{ agent: 'cto' }, { role: 'cto' }, { instance: 'cto' }, { business: 'other' }, { token: SECRET }]) {
|
||||
assert.throws(() => f.broker.request(f.cap, { verb: 'my_rails', args }), { code: 'invalid-request' });
|
||||
}
|
||||
const rails = f.broker.request(f.cap, { verb: 'my_rails' });
|
||||
const text = JSON.stringify(rails) + railsText(rails);
|
||||
for (const value of [SECRET, 'placeholder-not-a-token', f.resolved.coder.credentials.gitea.file, f.resolved.cto.credentials.gitea.file]) assert.ok(!text.includes(value), value);
|
||||
const human = f.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
assert.throws(() => f.broker.request(human, { verb: 'my_rails' }), { code: 'agent-required' });
|
||||
});
|
||||
|
||||
function raw(path, request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = connect(path); let out = '';
|
||||
socket.on('error', reject);
|
||||
socket.on('connect', () => socket.end(JSON.stringify(request) + '\n'));
|
||||
socket.on('data', b => out += b);
|
||||
socket.on('end', () => resolve(JSON.parse(out)));
|
||||
});
|
||||
}
|
||||
test('cross-role refusal envelope names its rail and next step without changing code or outcome', async t => {
|
||||
const f = fixture(t), path = join(f.root, 'bus.sock');
|
||||
const server = await serve({ broker: f.broker, path }); t.after(() => server.close());
|
||||
const request = { cap: f.cap, verb: 'decision.resolve', args: { id: 'missing', choice: SECRET } };
|
||||
const refused = await raw(path, request);
|
||||
assert.equal(refused.ok, false); assert.equal(refused.error, 'decision-not-found');
|
||||
assert.deepEqual({ rail: refused.rail, nextStep: refused.nextStep }, REFUSALS['decision-not-found']);
|
||||
assert.ok(!JSON.stringify(refused).includes(SECRET));
|
||||
await assert.rejects(new Client({ path, cap: f.cap }).call('decision.resolve', request.args), e => {
|
||||
assert.equal(e.code, 'decision-not-found'); assert.equal(e.nextStep, refused.nextStep); return true;
|
||||
});
|
||||
const decision = f.broker.request(f.cap, { verb: 'decision.raise', args: { action: 'task.scope.change', domain: 'delivery',
|
||||
question: 'Scope?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'no', blocking: false } });
|
||||
const cross = await raw(path, { cap: f.cap, verb: 'decision.resolve', args: { id: decision.id, choice: 'yes' } });
|
||||
assert.equal(cross.error, 'not-resolver'); assert.match(cross.rail, /resolve right/); assert.match(cross.nextStep, /routed resolver/);
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decision_events WHERE decision=?', decision.id).n, 0);
|
||||
// Exercise an actual cross-role action without its required decision, not just resolve.
|
||||
const live = busOrganization(f.business, f.resolved);
|
||||
live.roles.coder.authority.crossRole.push('role.end');
|
||||
f.broker.reloadBusiness('acme', live);
|
||||
const decisions = f.store.get('SELECT count(*) n FROM decisions').n;
|
||||
const required = await raw(path, { cap: f.cap, verb: 'role.revoke', args: { role: 'reviewer', reason: SECRET } });
|
||||
assert.equal(required.ok, false); assert.equal(required.error, 'decision-required');
|
||||
assert.deepEqual({ rail: required.rail, nextStep: required.nextStep }, REFUSALS['decision-required']);
|
||||
assert.ok(!JSON.stringify(required).includes(SECRET));
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, decisions);
|
||||
});
|
||||
|
||||
test('every literal fail branch in broker has static guidance; guidance never contains payload values', () => {
|
||||
const source = readFileSync(new URL('../src/broker.mjs', import.meta.url), 'utf8');
|
||||
// Balance each call (including ternaries), so a following statement cannot masquerade
|
||||
// as a fail code. Every literal branch, with either quote style, must be in the table.
|
||||
for (const match of source.matchAll(/\bfail\s*\(/g)) {
|
||||
let depth = 1, i = match.index + match[0].length;
|
||||
while (depth && i < source.length) {
|
||||
const char = source[i++];
|
||||
if (char === "'" || char === '"') {
|
||||
const start = i - 1;
|
||||
let value = '';
|
||||
while (i < source.length && source[i] !== char) {
|
||||
if (source[i] === "\\") { i += 2; continue; }
|
||||
value += source[i++];
|
||||
}
|
||||
i++;
|
||||
if (/^[a-z][a-z0-9-]*$/.test(value) && (value.includes('-') || !source.slice(match.index + match[0].length, start).trim())) assert.ok(Object.hasOwn(REFUSALS, value), value);
|
||||
} else if (char === '(') depth++;
|
||||
else if (char === ')') depth--;
|
||||
}
|
||||
assert.equal(depth, 0, 'balanced fail call');
|
||||
}
|
||||
// Finite delegated fail(code) sources must be covered as well as literal branches.
|
||||
for (const code of [...PROXY_PAGE_CODES, ...GUARD_CODES, 'project-busy', 'remote-host-mismatch', 'remote-not-fork',
|
||||
'processing-control-unsafe', 'processing-uninitialized', 'processing-generation-changed']) {
|
||||
assert.ok(Object.hasOwn(REFUSALS, code), code);
|
||||
}
|
||||
for (const [code, guidance] of Object.entries(REFUSALS)) {
|
||||
assert.ok(guidance.rail.length > 0 && guidance.nextStep.length > 0, code);
|
||||
assert.deepEqual({ rail: new BusError(code).rail, nextStep: new BusError(code).nextStep }, guidance);
|
||||
assert.ok(!JSON.stringify(guidance).includes(SECRET));
|
||||
}
|
||||
assert.deepEqual(refusalGuidance(SECRET), refusalGuidance('unknown-external-code'));
|
||||
});
|
||||
|
||||
test('finite forwarded project, library and request errors have static next steps', () => {
|
||||
const modules = [
|
||||
...['index', 'git', 'paths'].map(name => new URL(`../../project/src/${name}.mjs`, import.meta.url)),
|
||||
...readdirSync(new URL('../../library/src/', import.meta.url)).filter(f => f.endsWith('.mjs'))
|
||||
.map(name => new URL(`../../library/src/${name}`, import.meta.url)),
|
||||
...['request-store', 'request-lifecycle'].map(name => new URL(`../src/${name}.mjs`, import.meta.url)),
|
||||
];
|
||||
for (const file of modules) {
|
||||
const source = readFileSync(file, 'utf8');
|
||||
for (const match of source.matchAll(/\b(?:new\s+(?:ProjectError|LibraryError|RequestError|GuardError)|raise|refuse|fail)\s*\(\s*['"]([a-z][a-z0-9-]*)['"]/g)) {
|
||||
assert.ok(Object.hasOwn(REFUSALS, match[1]), `${file.pathname}: ${match[1]}`);
|
||||
}
|
||||
}
|
||||
assert.doesNotMatch(REFUSALS['outcome-unknown'].nextStep, /confirmation|human/);
|
||||
assert.doesNotMatch(REFUSALS['revision-conflict'].nextStep, /confirmation|human/);
|
||||
assert.match(REFUSALS['task-required'].nextStep, /task_ref.*blocking false/);
|
||||
});
|
||||
@@ -211,13 +211,13 @@ test('T14 reloadBusiness refuses an unknown business, a changed human and a docu
|
||||
const root = mkdtempSync(join(tmpdir(), 'reload-unit-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const role = { authority: { withinRole: [], crossRole: [] } };
|
||||
const demo = { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: role } };
|
||||
const demo = { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: role } };
|
||||
const b = new Broker({ store: new Store(root), businesses: { demo } });
|
||||
assert.equal(code(() => b.reloadBusiness('other', demo)), 'unknown-business');
|
||||
assert.equal(code(() => b.reloadBusiness('demo', { ...demo, human: 'mos' })), 'invalid-business');
|
||||
assert.equal(code(() => b.reloadBusiness('demo', { ...demo, roles: { coder: role, tester: role } })), 'invalid-business');
|
||||
assert.equal(code(() => b.reloadBusiness('demo', { ...demo, roles: { coder: { authority: { withinRole: ['spend'], crossRole: [] } } } })), 'invalid-authority');
|
||||
assert.equal(code(() => b.reloadBusiness('demo', null)), 'invalid-business');
|
||||
b.reloadBusiness('demo', { ...demo, roles: { coder: role, 'sec-1': role } });
|
||||
b.reloadBusiness('demo', { ...demo, roles: { ...demo.roles, 'sec-1': role } });
|
||||
assert.ok(b.bindLaunch({ business: 'demo', role: 'sec-1', run: 'run-1', harness: 'pi' }));
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
// RR (#1873): resolution checks current role rights, and callers cannot choose a merge/policy arbiter.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { loadRole } from '../../organization/src/index.mjs';
|
||||
import { REPO_ROLES } from '../../organization/tests/helpers.mjs';
|
||||
import { fixture } from './spec-fixtures.mjs';
|
||||
import { grantDecision } from './host-fixtures.mjs';
|
||||
|
||||
async function setup(t, { shared = false } = {}) {
|
||||
const roles = Object.fromEntries(['cto', 'pm', 'chief-of-staff'].map(definition => {
|
||||
const role = loadRole(REPO_ROLES, definition);
|
||||
return [definition === 'chief-of-staff' ? 'chief' : definition, { definition, authority: structuredClone(role.authority) }];
|
||||
}));
|
||||
roles.coder = { definition: 'coder', authority: { withinRole: ['message.send', 'record.write'], crossRole: ['task.scope.change', 'git.merge.protected', 'policy.change'] } };
|
||||
const f = await fixture(t, { roles, arbiters: { technical: 'cto', delivery: shared ? 'cto' : 'pm' } });
|
||||
return { ...f, roles, cto: f.agent('cto'), pm: f.agent('pm'), chief: f.chief };
|
||||
}
|
||||
|
||||
for (const [domain, resolver] of [['technical', 'cto'], ['delivery', 'pm']]) {
|
||||
test(`RR: ${domain} arbiter that loses its right refuses not-resolver`, async t => {
|
||||
const f = await setup(t);
|
||||
const d = f.raise({ action: 'task.scope.change', domain });
|
||||
f.business.roles[resolver].authority.withinRole = ['message.send'];
|
||||
f.broker.reloadBusiness('demo', f.business);
|
||||
assert.throws(() => f.call(f[resolver], 'decision.resolve', { id: d.id, choice: 'yes' }), error => {
|
||||
assert.equal(error.code, 'not-resolver');
|
||||
assert.match(error.reason, new RegExp(`${resolver}.*${resolver}.*decision\\.resolve\\.${domain}`));
|
||||
return true;
|
||||
});
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decision_events WHERE decision=?', d.id).n, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test('RR: a shared arbiter cannot resolve a delivery decision using only its technical right', async t => {
|
||||
const f = await setup(t, { shared: true });
|
||||
const d = f.raise({ action: 'task.scope.change', domain: 'delivery' });
|
||||
assert.equal(d.route_to, 'cto');
|
||||
assert.throws(() => f.call(f.cto, 'decision.resolve', { id: d.id, choice: 'yes' }), { code: 'not-resolver' });
|
||||
});
|
||||
|
||||
test('RR: merge raised with delivery still routes to CTO and resolves (acceptance 314 step 5)', async t => {
|
||||
const f = await setup(t);
|
||||
const d = await f.raise({ action: 'git.merge.protected', domain: 'delivery', project: 'stack', spec_path: 'spec/north-star.md', spec_commit: f.commit });
|
||||
assert.equal(d.route_to, 'cto');
|
||||
assert.throws(() => f.call(f.pm, 'decision.resolve', { id: d.id, choice: 'yes' }), { code: 'not-resolver' });
|
||||
f.call(f.cto, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
assert.equal(f.store.get('SELECT op FROM decision_events WHERE decision=?', d.id).op, 'resolved');
|
||||
});
|
||||
|
||||
for (const resolver of ['chief', 'pm']) {
|
||||
test(`RR: ${resolver} named delivery arbiter resolves policy.change even when raised technical`, async t => {
|
||||
const f = await setup(t); f.business.arbiters.delivery = resolver;
|
||||
f.broker.reloadBusiness('demo', f.business);
|
||||
const d = f.raise({ action: 'policy.change', domain: 'technical' });
|
||||
assert.equal(d.route_to, resolver);
|
||||
f.call(f[resolver], 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
assert.equal(f.store.get('SELECT by FROM decision_events WHERE decision=?', d.id).by, `${resolver}-run`);
|
||||
});
|
||||
}
|
||||
|
||||
test('RR: the chief holds both rights when named for both domains', async t => {
|
||||
const f = await setup(t); f.business.arbiters = { technical: 'chief', delivery: 'chief' };
|
||||
f.broker.reloadBusiness('demo', f.business);
|
||||
for (const action of ['git.merge.protected', 'policy.change']) {
|
||||
const d = f.raise({ action }); assert.equal(d.route_to, 'chief');
|
||||
f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
}
|
||||
});
|
||||
|
||||
// Old raise evidence did not preserve domain. Current assignments cannot prove the old domain.
|
||||
test('RR: remapping arbiters cannot let a legacy decision resolve with only the other domain right', async t => {
|
||||
const f = await setup(t);
|
||||
const id = grantDecision(f.store, { action: 'task.scope.change', route: 'pm', domain: null, close: null });
|
||||
f.business.arbiters = { technical: 'pm', delivery: 'cto' };
|
||||
f.business.roles.pm.authority.withinRole = ['message.send', 'decision.resolve.technical'];
|
||||
f.broker.reloadBusiness('demo', f.business);
|
||||
assert.throws(() => f.call(f.pm, 'decision.resolve', { id, choice: 'yes' }), { code: 'not-resolver' });
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decision_events WHERE decision=?', id).n, 0);
|
||||
// Human recovery closes the old route by withdrawing it, before the raiser creates a new decision.
|
||||
f.call(f.human, 'decision.withdraw', { id });
|
||||
assert.equal(f.store.get('SELECT op FROM decision_events WHERE decision=?', id).op, 'withdrawn');
|
||||
});
|
||||
@@ -28,7 +28,7 @@ test('C2-10c check hydration preserves unchecked rows and one broadcast with two
|
||||
const messaging = JSON.parse(readFileSync(new URL('../../../roles/companion.json', import.meta.url))).messaging;
|
||||
const role = { definition: 'companion', authority: { withinRole: ['message.send'], crossRole: [] } };
|
||||
const broker = new Broker({ store, businesses: { demo: { id: 'demo', human: 'jason', vars: {},
|
||||
arbiters: { technical: 'bean', delivery: 'bean' }, roles: { bean: role, chet: role }, definitions: { companion: { messaging } } } } });
|
||||
arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, bean: role, chet: role }, definitions: { companion: { messaging } } } } });
|
||||
const human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const call = (verb, args) => broker.request(human, { verb, args });
|
||||
const project = call('project.create', { name: 'Room' }).id;
|
||||
|
||||
@@ -110,7 +110,7 @@ test('row 65 runtime: routes and routes.set reach the organization file with the
|
||||
const file = join(root, 'businesses/demo.json');
|
||||
writeJson(file, doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
t.after(() => bus.close());
|
||||
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const log = () => readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
|
||||
@@ -157,7 +157,7 @@ test('fixture provider catalogue appears in actual routes and materializes the s
|
||||
const file = join(root, 'businesses/demo.json'); writeJson(file, doc); mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const offered={ key:'zai-fixture-id',module:'cliproxy',upstream:'zai',provider:'zai',model:'fixture-id',label:'fixture-id',available:true,reason:null,harnesses:['pi'] };
|
||||
const catalogues={ read:async()=>({status:'ready',observedAt:'2026-11-06T00:00:00.000Z',models:[offered],notices:[]}),invalidate:()=>{} };
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}} } }, organization:{dir:root,rolesDir,system:systemFor(root)}, modelCatalogue:catalogues });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}} } }, organization:{dir:root,rolesDir,system:systemFor(root)}, modelCatalogue:catalogues });
|
||||
t.after(() => bus.close());
|
||||
const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
const before=await bus.broker.request(human,{verb:'routes'});
|
||||
@@ -179,7 +179,7 @@ for (const writer of ['limits.set','organization.set','instances.set']) test(`${
|
||||
let mode='absent';
|
||||
const offered={key:'fixture-persisted',module:'cliproxy',upstream:'zai',provider:'zai',model:'fixture-id',label:'fixture',available:true,reason:null,harnesses:['pi']};
|
||||
const catalogue={read:async()=>({status:'ready',observedAt:'2026-11-06T00:00:00.000Z',models:mode==='absent'?[]:[{...offered,harnesses:mode==='incompatible'?['claude-code']:['pi']}],notices:[]}),invalidate(){}};
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}},chief:{definition:'chief-of-staff',authority:chiefAuthority}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue:catalogue});t.after(()=>bus.close());
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{coder:{authority:{withinRole:[],crossRole:[]}},chief:{definition:'chief-of-staff',authority:chiefAuthority}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue:catalogue});t.after(()=>bus.close());
|
||||
const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
const cap=writer==='organization.set'?bus.broker.bindLaunch({business:'demo',role:'chief',run:'provider-chief',harness:'pi'}):human;
|
||||
if(writer==='organization.set')bus.broker.request(cap,{verb:'role.claim'});
|
||||
@@ -201,7 +201,7 @@ test('legacy routes follow enabled module mappings while unchanged unavailable s
|
||||
writeJson(join(root,'businesses/demo.json'),doc);mkdirSync(join(root,'data'),{mode:0o700});
|
||||
let state='ready';const offered={key:'zai-fixture-two',module:'cliproxy',upstream:'zai',provider:'zai',model:'fixture-two',label:'fixture',available:true,reason:null,harnesses:['pi']};
|
||||
const catalogue={read:async()=>({status:state,observedAt:'2026-11-06T00:00:00.000Z',models:[offered],notices:[]}),invalidate(){}};
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue:catalogue});t.after(()=>bus.close());
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue:catalogue});t.after(()=>bus.close());
|
||||
const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});const request=(verb,args)=>bus.broker.request(human,{verb,args});
|
||||
let view=await request('routes');assert.deepEqual(view.catalogue.legacy['legacy-one'],{module:'cliproxy',upstream:'claude',model:'fixture-one'});
|
||||
const pick=(harness,model,fallback=null)=>({harness,model,thinking:'low',fallback});
|
||||
|
||||
@@ -12,8 +12,8 @@ const businesses = {
|
||||
arbiters: { technical: 'cto', delivery: 'pm' },
|
||||
roles: {
|
||||
coder: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } },
|
||||
cto: { authority: { withinRole: [], crossRole: [] } },
|
||||
pm: { authority: { withinRole: [], crossRole: [] } },
|
||||
cto: { authority: { withinRole: ['decision.resolve.technical'], crossRole: [] } },
|
||||
pm: { authority: { withinRole: ['decision.resolve.delivery'], crossRole: [] } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Broker } from '../src/broker.mjs';
|
||||
import { initProject, readSpec, writeRecord, listProjectFiles, readProjectFile, writeHumanFile, ProjectError } from '../../project/src/index.mjs';
|
||||
export const git = (repo, ...args) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'maintenance.auto=false', '-c', 'gc.auto=0', '-C', repo, ...args], { encoding: 'utf8' }).trim();
|
||||
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
|
||||
export async function fixture(t, { adapter = true, roles = {}, arbiters = { technical: 'reviewer', delivery: 'reviewer' }, access = null } = {}) {
|
||||
export async function fixture(t, { adapter = true, roles = {}, arbiters = { technical: 'chief', delivery: 'chief' }, access = null } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-spec-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
@@ -16,6 +16,7 @@ export async function fixture(t, { adapter = true, roles = {}, arbiters = { tech
|
||||
projects: { stack: { root: join(root, 'code'), repo: join(root, 'records') } }, roles: {
|
||||
coder: { definition: 'coder', authority: authority(['message.send', 'record.write']) },
|
||||
reviewer: { definition: 'reviewer', authority: authority(['message.send']) },
|
||||
chief: { definition: 'chief-of-staff', authority: authority(['message.send', 'decision.resolve.technical', 'decision.resolve.delivery']) },
|
||||
...roles,
|
||||
} };
|
||||
const { repo, commit } = await initProject({ business, project: 'stack', dataRoot: root });
|
||||
@@ -31,7 +32,7 @@ export async function fixture(t, { adapter = true, roles = {}, arbiters = { tech
|
||||
const cap = broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi', address: role });
|
||||
broker.request(cap, { verb: 'role.claim' }); return cap;
|
||||
};
|
||||
const coder = agent('coder'), reviewer = agent('reviewer');
|
||||
const coder = agent('coder'), reviewer = agent('reviewer'), chief = agent('chief');
|
||||
const human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const call = (cap, verb, args = {}) => broker.request(cap, { verb, args });
|
||||
const raise = args => call(coder, 'decision.raise', { action: 'deploy', question: 'Ship?', options,
|
||||
@@ -42,5 +43,5 @@ export async function fixture(t, { adapter = true, roles = {}, arbiters = { tech
|
||||
git(repo, '-c', `user.name=${author}`, '-c', '[email protected]', 'commit', '-m', 'Spec');
|
||||
return git(repo, 'rev-parse', 'HEAD');
|
||||
};
|
||||
return { root, store, business, broker, repo, commit, agent, coder, reviewer, human, call, raise, save };
|
||||
return { root, store, business, broker, repo, commit, agent, coder, reviewer, chief, human, call, raise, save };
|
||||
}
|
||||
@@ -19,7 +19,7 @@ async function setup(t, { running = ['chief', 'pm', 'cto'], thread = true } = {}
|
||||
cto: { definition: 'cto', ...within }, assistant: { definition: 'assistant', ...within },
|
||||
} });
|
||||
f.business.definitions = { assistant: { messaging: shipped('assistant').messaging } };
|
||||
f.cap = Object.fromEntries(running.map(role => [role, f.agent(role)]));
|
||||
f.cap = Object.fromEntries(running.map(role => [role, role === 'chief' ? f.chief : f.agent(role)]));
|
||||
if (thread) {
|
||||
const project = f.call(f.human, 'project.create', { name: 'stack work', repo: 'stack' }).id;
|
||||
f.threadId = f.call(f.human, 'thread.create', { project, name: 'alpha' }).id;
|
||||
|
||||
@@ -139,7 +139,7 @@ test('REQ-COS-1: the request goes to the business launcher, and only when that i
|
||||
// launcher `chief`, so a check on the name passes there; these use names that differ from the definitions.
|
||||
test('REQ-COS-1: the sender is judged by definition: an instance `lead` of the pm definition can ask, an instance `pm` of the coder definition can\'t', (t) => {
|
||||
const lead = setup(t, {
|
||||
arbiters: { technical: 'lead', delivery: 'chief' },
|
||||
arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', authority: authority.chief },
|
||||
lead: { definition: 'pm', authority: authority.pm },
|
||||
@@ -172,9 +172,9 @@ test('REQ-COS-1: the sender is judged by definition: an instance `lead` of the p
|
||||
|
||||
test('REQ-COS-1: the launcher is judged by definition: an instance `boss` of the chief-of-staff definition receives, a `chief` of the coder definition gives no-chief-of-staff', (t) => {
|
||||
const boss = setup(t, {
|
||||
arbiters: { technical: 'pm', delivery: 'boss' },
|
||||
arbiters: { technical: 'pm', delivery: 'chief' },
|
||||
launch: { by: 'boss', instances: ['coder'], max: { opus: 2, sonnet: 2 } },
|
||||
roles: {
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
boss: { definition: 'chief-of-staff', authority: authority.chief },
|
||||
pm: { definition: 'pm', authority: authority.pm },
|
||||
coder: { definition: 'coder', ...none },
|
||||
|
||||
@@ -91,7 +91,7 @@ test('runtime reads the current organization file for project selections and con
|
||||
doc.plugins = { 'system-type': ['coding'] };
|
||||
writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } };
|
||||
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } };
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: business }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
|
||||
@@ -46,8 +46,8 @@ async function runtime(t) {
|
||||
dataRoot: join(root, 'data'),
|
||||
businesses: {
|
||||
demo: {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: { 'bus.tester': true },
|
||||
roles: { coder: { definition: 'coder', ...none }, assistant: { definition: 'assistant', ...none } },
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', ...none }, assistant: { definition: 'assistant', ...none } },
|
||||
},
|
||||
},
|
||||
organization: { dir: root, rolesDir, system: systemFor(root) },
|
||||
@@ -166,8 +166,8 @@ function broker(t, vars = { 'bus.tester': true }) {
|
||||
store,
|
||||
businesses: {
|
||||
demo: {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'lead', delivery: 'lead' }, ...(vars ? { vars } : {}),
|
||||
roles: { dev: { definition: 'coder', ...none }, lead: { definition: 'pm', ...none } },
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, ...(vars ? { vars } : {}),
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, dev: { definition: 'coder', ...none }, lead: { definition: 'pm', ...none } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Store } from '../src/store.mjs';
|
||||
import { Broker, TASK_VERBS } from '../src/broker.mjs';
|
||||
import { serve } from '../src/server.mjs';
|
||||
import { Client } from '../src/client.mjs';
|
||||
import { refusalGuidance } from '../src/refusals.mjs';
|
||||
import { startBroker } from '../src/runtime.mjs';
|
||||
import { readProcess, verifyHuman } from '../src/human.mjs';
|
||||
import { verifyTester, testerNonce } from '../src/tester.mjs';
|
||||
@@ -387,9 +388,9 @@ test('an envelope carries exactly one of cap, human and tester; no tester authen
|
||||
const cap = b.bindReader({ business: 'demo' });
|
||||
const role = b.bindLaunch({ business: 'demo', role: 'pm', run: 'pm-run', harness: 'pi' });
|
||||
for (const r of [{ tester: p, cap }, { tester: p, cap: role }, { tester: p, human: p }, { tester: p, human: p, cap }])
|
||||
assert.deepEqual(await raw(path, { ...r, verb: 'agents' }), { ok: false, error: 'invalid-envelope' }, Object.keys(r).join('+'));
|
||||
assert.deepEqual(await raw(path, { ...r, verb: 'agents' }), { ok: false, error: 'invalid-envelope', ...refusalGuidance('invalid-envelope') }, Object.keys(r).join('+'));
|
||||
assert.equal(calls, 0);
|
||||
assert.deepEqual(await raw(bare, { tester: p, verb: 'agents' }), { ok: false, error: 'tester-required' });
|
||||
assert.deepEqual(await raw(bare, { tester: p, verb: 'agents' }), { ok: false, error: 'tester-required', ...refusalGuidance('tester-required') });
|
||||
assert.deepEqual(await raw(path, { tester: p, verb: 'agents' }), { ok: true, result: [] });
|
||||
assert.equal(calls, 1);
|
||||
// The tester's capability lasts one request, as the human's does.
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Store } from '../src/store.mjs';
|
||||
import { Broker } from '../src/broker.mjs';
|
||||
import { serve } from '../src/server.mjs';
|
||||
import { Client } from '../src/client.mjs';
|
||||
import { refusalGuidance } from '../src/refusals.mjs';
|
||||
import { views } from '../src/views.mjs';
|
||||
const businesses = {
|
||||
demo: {
|
||||
@@ -72,7 +73,10 @@ test('malformed, oversized and identity-forging envelopes refuse without echoing
|
||||
const out = await raw(input);
|
||||
assert.equal(JSON.parse(out).ok, false);
|
||||
assert.ok(!out.includes('not-json'));
|
||||
assert.ok(out.length < 150);
|
||||
const refusal = JSON.parse(out);
|
||||
assert.ok(['invalid-envelope', 'request-too-large'].includes(refusal.error));
|
||||
assert.deepEqual(refusal, { ok: false, error: refusal.error, ...refusalGuidance(refusal.error) });
|
||||
assert.ok(out.length < 1024); // A bounded static description, independent of input size.
|
||||
}
|
||||
});
|
||||
test('client preserves UTF-8 when a response divides a multibyte character', async (t) => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Broker } from '../src/broker.mjs';
|
||||
test('REQ-LIMIT-4 usage protocol binds run identity, requires a delivered turn and isolates businesses', async t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'usage-broker-'));
|
||||
const store = new Store(root); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const businesses = { demo: { id: 'demo', human: 'jason', roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } }, arbiters: { technical: 'coder', delivery: 'coder' } } };
|
||||
const businesses = { demo: { id: 'demo', human: 'jason', roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } }, arbiters: { technical: 'chief', delivery: 'chief' } } };
|
||||
const recorded = [];
|
||||
const broker = new Broker({ store, businesses, usage: {
|
||||
collect: async (...args) => { recorded.push(args); return { recorded: true }; },
|
||||
|
||||
@@ -12,7 +12,7 @@ test('REQ-PLUG-1 runtime reaches transcript only through loader, over the real b
|
||||
t.after(async () => { await runtime?.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const dir = join(root, 'launches/demo/run1/session'); mkdirSync(dir, { recursive: true });
|
||||
const business = { id: 'demo', human: 'jason', plugins: { 'usage-source': ['transcript'], harness: ['pi'] },
|
||||
roles: { coder: { authority: { withinRole: [], crossRole: [] } } }, arbiters: { technical: 'coder', delivery: 'coder' } };
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } }, arbiters: { technical: 'chief', delivery: 'chief' } };
|
||||
runtime = await startBroker({ dataRoot: root, businesses: { demo: business }, launches: [{
|
||||
// Row 65: the launch names where its harness keeps the session files.
|
||||
business: 'demo', role: 'coder', run: 'run1', harness: 'pi', provider: 'other-provider', model: 'other-model', transcript: dir,
|
||||
|
||||
@@ -65,10 +65,10 @@ test("merge-gate's technical raise is cross-role, goes to the cto, and only merg
|
||||
assert.throws(() => b.authorize(caps['merge-gate'], 'git.merge.protected', { decision: d.id, target: TASK }), /decision-consumed/);
|
||||
});
|
||||
|
||||
test("merge-gate has no message.send, and without a domain its raise goes to the delivery arbiter", (t) => {
|
||||
test("merge-gate has no message.send, and without a domain its merge raise still goes to the technical arbiter", (t) => {
|
||||
const { call, raise } = setup(t);
|
||||
assert.throws(() => call('merge-gate', 'message.send', { to: 'cto', body: 'landed' }), /decision-required/);
|
||||
assert.equal(raise('merge-gate', 'git.merge.protected', { target: TASK }).route_to, 'pm');
|
||||
assert.equal(raise('merge-gate', 'git.merge.protected', { target: TASK }).route_to, 'cto');
|
||||
// Every other action is gated for merge-gate, so an uncited raise goes to the human.
|
||||
const push = raise('merge-gate', 'git.push.working', { domain: 'technical', target: TASK });
|
||||
assert.deepEqual([push.class, push.route_to], ['gated', 'human']);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// Old path of packages/organization/src/accounts.mjs (rename design r2, stage B).
|
||||
export * from "../../organization/src/accounts.mjs";
|
||||
@@ -0,0 +1,2 @@
|
||||
// Old path of packages/organization/src/agent-copies.mjs (rename design r2, stage B).
|
||||
export * from "../../organization/src/agent-copies.mjs";
|
||||
@@ -0,0 +1,2 @@
|
||||
// Old path of packages/organization/src/agent-levels-write.mjs (rename design r2, stage B).
|
||||
export * from "../../organization/src/agent-levels-write.mjs";
|
||||
@@ -0,0 +1,2 @@
|
||||
// Old path of packages/organization/src/composition.mjs (rename design r2, stage B).
|
||||
export * from "../../organization/src/composition.mjs";
|
||||
@@ -0,0 +1,2 @@
|
||||
// Compatibility path for the organization package's caller-rails projection.
|
||||
export * from "../../organization/src/rails.mjs";
|
||||
@@ -1 +1 @@
|
||||
{"business":"acme","contract":"<ROOT>/roles/coder.md","credentials":{"gitea":{"file":"<ROOT>/secrets/coder-gitea.token","rotateBy":"2099-01-01","service":"gitea"},"vikunja":{"expires":"2099-01-01","file":"<ROOT>/secrets/coder-vikunja.token","service":"vikunja"}},"definition":"coder","holder":null,"instance":"coder","launch":null,"limits":{"authority":{"crossRole":["task.scope.change"],"withinRole":["task.update.assigned","git.push.working","review.request","message.send","record.write"]},"network":"none","tools":["read","write","edit","bash","grep","find","ls"]},"project":"stack","provenance":{"bus.tester":"default","dataRoot":"system","environment":"system","execution.adapter":"system","execution.backend":"system","execution.model":"system","execution.provider":"system","gitea.baseUrl":"business:acme","harness":"business:acme:roles.coder","limits.network":["business:acme:roles.coder"],"tracker.baseUrl":"business:acme","tracker.descendants":"default","tracker.kind":"default","tracker.pollSeconds":"business:acme","tracker.project":"project:stack","tracker.reconcileMinutes":"default"},"skills":["ms-unslop","ms-sdlc","ms-file-read","ms-file-write"],"tracker":{"bot":"bot-acme-coder","botId":5},"vars":{"bus.tester":false,"dataRoot":"<ROOT>/data","environment":"development","execution.adapter":"mock","execution.backend":"docker","execution.model":"glm-5.3-flash","execution.provider":"zai","gitea.baseUrl":"https://git.example","harness":"pi","limits.network":"none","tracker.baseUrl":"http://127.0.0.1:3456","tracker.descendants":false,"tracker.kind":"vikunja","tracker.pollSeconds":60,"tracker.project":3,"tracker.reconcileMinutes":60},"webSource":null}
|
||||
{"business":"acme","contract":"<ROOT>/roles/coder.md","credentials":{"gitea":{"file":"<ROOT>/secrets/coder-gitea.token","rotateBy":"2099-01-01","service":"gitea"},"vikunja":{"expires":"2099-01-01","file":"<ROOT>/secrets/coder-vikunja.token","service":"vikunja"}},"definition":"coder","holder":null,"instance":"coder","launch":null,"limits":{"authority":{"crossRole":["task.scope.change"],"withinRole":["task.update.assigned","git.push.working","review.request","message.send","record.write"]},"network":"none","tools":["read","write","edit","bash","grep","find","ls"]},"project":"stack","provenance":{"bus.tester":"default","dataRoot":"system","environment":"system","execution.adapter":"system","execution.backend":"system","execution.model":"system","execution.provider":"system","gitea.baseUrl":"business:acme","harness":"business:acme:roles.coder","limits.network":["business:acme:roles.coder"],"tracker.baseUrl":"business:acme","tracker.descendants":"default","tracker.kind":"default","tracker.pollSeconds":"business:acme","tracker.project":"project:stack","tracker.reconcileMinutes":"default"},"rails":{"actions":[{"action":"task.update.assigned","routes":[{"to":"alone"}]},{"action":"git.push.working","routes":[{"to":"alone"}]},{"action":"review.request","routes":[{"to":"alone"}]},{"action":"message.send","routes":[{"to":"alone"}]},{"action":"record.write","routes":[{"to":"alone"}]},{"action":"task.scope.change","routes":[{"domain":"delivery","to":"pm"},{"domain":"technical","to":"cto"}]}],"credentials":[{"scopes":["write:issue","write:repository","read:user"],"service":"gitea"},{"scopes":{"projects":["views_buckets_tasks"],"tasks":["read_one","update"],"tasks_comments":["create"]},"service":"vikunja"}],"messaging":null,"network":"none","resolve":[]},"skills":["ms-unslop","ms-sdlc","ms-file-read","ms-file-write"],"tracker":{"bot":"bot-acme-coder","botId":5},"vars":{"bus.tester":false,"dataRoot":"<ROOT>/data","environment":"development","execution.adapter":"mock","execution.backend":"docker","execution.model":"glm-5.3-flash","execution.provider":"zai","gitea.baseUrl":"https://git.example","harness":"pi","limits.network":"none","tracker.baseUrl":"http://127.0.0.1:3456","tracker.descendants":false,"tracker.kind":"vikunja","tracker.pollSeconds":60,"tracker.project":3,"tracker.reconcileMinutes":60},"webSource":null}
|
||||
@@ -67,6 +67,91 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"rails": {
|
||||
"actions": [
|
||||
{
|
||||
"action": "task.update.assigned",
|
||||
"routes": [
|
||||
{
|
||||
"to": "alone"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"action": "git.push.working",
|
||||
"routes": [
|
||||
{
|
||||
"to": "alone"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"action": "review.request",
|
||||
"routes": [
|
||||
{
|
||||
"to": "alone"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"action": "message.send",
|
||||
"routes": [
|
||||
{
|
||||
"to": "alone"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"action": "record.write",
|
||||
"routes": [
|
||||
{
|
||||
"to": "alone"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"action": "task.scope.change",
|
||||
"routes": [
|
||||
{
|
||||
"domain": "delivery",
|
||||
"to": "pm"
|
||||
},
|
||||
{
|
||||
"domain": "technical",
|
||||
"to": "cto"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"resolve": [],
|
||||
"network": "none",
|
||||
"credentials": [
|
||||
{
|
||||
"service": "gitea",
|
||||
"scopes": [
|
||||
"write:issue",
|
||||
"write:repository",
|
||||
"read:user"
|
||||
]
|
||||
},
|
||||
{
|
||||
"service": "vikunja",
|
||||
"scopes": {
|
||||
"tasks": [
|
||||
"read_one",
|
||||
"update"
|
||||
],
|
||||
"tasks_comments": [
|
||||
"create"
|
||||
],
|
||||
"projects": [
|
||||
"views_buckets_tasks"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"messaging": null
|
||||
},
|
||||
"credentials": {
|
||||
"gitea": {
|
||||
"service": "gitea",
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"business":"acme","contract":"/synthetic/roles/coder.md","credentials":{},"definition":"coder","holder":"example","instance":"coder","launch":null,"limits":{"authority":{"crossRole":[],"withinRole":["message.send"]},"network":"none","tools":["read","write"]},"project":"stack","provenance":{"bus.tester":"default","dataRoot":"system","environment":"system","execution.adapter":"system","execution.backend":"system","execution.model":"system","execution.provider":"system","limits.network":["business:acme:roles.coder"],"model":"business:acme:roles.coder","tracker.baseUrl":"business:acme","tracker.descendants":"default","tracker.kind":"default","tracker.pollSeconds":"project:stack:roles.coder","tracker.project":"project:stack","tracker.reconcileMinutes":"default"},"skills":[],"tracker":null,"vars":{"bus.tester":false,"dataRoot":"/synthetic/data","environment":"development","execution.adapter":"mock","execution.backend":"docker","execution.model":"system-model","execution.provider":"zai","limits.network":"none","model":"agent-model","tracker.baseUrl":"http://127.0.0.1:3456","tracker.descendants":false,"tracker.kind":"vikunja","tracker.pollSeconds":15,"tracker.project":3,"tracker.reconcileMinutes":60},"webSource":null}
|
||||
{"business":"acme","contract":"/synthetic/roles/coder.md","credentials":{},"definition":"coder","holder":"example","instance":"coder","launch":null,"limits":{"authority":{"crossRole":[],"withinRole":["message.send"]},"network":"none","tools":["read","write"]},"project":"stack","provenance":{"bus.tester":"default","dataRoot":"system","environment":"system","execution.adapter":"system","execution.backend":"system","execution.model":"system","execution.provider":"system","limits.network":["business:acme:roles.coder"],"model":"business:acme:roles.coder","tracker.baseUrl":"business:acme","tracker.descendants":"default","tracker.kind":"default","tracker.pollSeconds":"project:stack:roles.coder","tracker.project":"project:stack","tracker.reconcileMinutes":"default"},"rails":{"actions":[{"action":"message.send","routes":[{"to":"alone"}]}],"credentials":[],"messaging":null,"network":"none","resolve":[]},"skills":[],"tracker":null,"vars":{"bus.tester":false,"dataRoot":"/synthetic/data","environment":"development","execution.adapter":"mock","execution.backend":"docker","execution.model":"system-model","execution.provider":"zai","limits.network":"none","model":"agent-model","tracker.baseUrl":"http://127.0.0.1:3456","tracker.descendants":false,"tracker.kind":"vikunja","tracker.pollSeconds":15,"tracker.project":3,"tracker.reconcileMinutes":60},"webSource":null}
|
||||
+17
-1
@@ -54,10 +54,26 @@
|
||||
"crossRole": []
|
||||
}
|
||||
},
|
||||
"rails": {
|
||||
"actions": [
|
||||
{
|
||||
"action": "message.send",
|
||||
"routes": [
|
||||
{
|
||||
"to": "alone"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"resolve": [],
|
||||
"network": "none",
|
||||
"credentials": [],
|
||||
"messaging": null
|
||||
},
|
||||
"credentials": {},
|
||||
"tracker": null,
|
||||
"launch": null,
|
||||
"webSource": null,
|
||||
"skills": [],
|
||||
"digest": "975144f5fcd6989c3393175b1713908c632c04f378b5f28a95cb2f4e552fbefa"
|
||||
"digest": "5a8df768f2359fd8c0131eb5e8ce8d427f68d6d465ba20d42b413a280a922ef0"
|
||||
}
|
||||
@@ -117,7 +117,7 @@ test('installed wrapper pins v1 validate and resolve output, preimage and digest
|
||||
'installed-resolve.json': normalized(resolved),
|
||||
'installed-preimage.json': normalized(canonicalJson(preimage)) + '\n',
|
||||
};
|
||||
assert.equal(sha256(snapshots['installed-preimage.json'].slice(0, -1)), '5fc9d6258f4725b0e260d99e5cf81058a05d21b8ff298bb53653efbbc8013da9');
|
||||
assert.equal(sha256(snapshots['installed-preimage.json'].slice(0, -1)), '0a12b35f55d033b91bd89745859028d252be52b05ca4083964b778a27ec20fd6');
|
||||
for (const [name, actual] of Object.entries(snapshots)) {
|
||||
assert.equal(actual, fixture(name), name);
|
||||
}
|
||||
|
||||
@@ -1298,3 +1298,32 @@ shows selected, active and inactive ids. The owner replaces the selection with
|
||||
`mosaic project system-types-set PROJECT --types coding,montage --org ID`; an
|
||||
empty `--types ''` clears it. Disabled selections stay recorded but become
|
||||
inactive. Project settings changes are append-only and require human proof.
|
||||
|
||||
## Accounts
|
||||
|
||||
`mosaic accounts` administers the instance-level accounts store locally, as the
|
||||
effective uid owning the config directory. It requires a human shell, rejects `--tester`,
|
||||
and takes no business selector or bus connection. It has no organization rights
|
||||
verbs. Console sign-in, sessions and membership evaluation are later Phase M
|
||||
slices; these commands alone do not turn accounts mode on.
|
||||
|
||||
```sh
|
||||
mosaic accounts init --name Operator
|
||||
mosaic accounts list --json
|
||||
mosaic accounts user add --name Pat --subject pocket:verified-subject --revision 1
|
||||
mosaic accounts user disable u-0123456789ab --revision 2
|
||||
mosaic accounts user enable u-0123456789ab --revision 3
|
||||
mosaic accounts user signout u-0123456789ab --revision 4
|
||||
mosaic accounts user remove u-0123456789ab --revision 5
|
||||
```
|
||||
|
||||
Init creates one operator account containing every configured `console.sso`
|
||||
subject and refuses if accounts.json already exists. Add mints its id and defaults
|
||||
to instance role `user`; `--role operator` creates another operator. Writes require
|
||||
the file revision from list. Disable, enable and signout advance `authGen`, and
|
||||
the last active operator cannot be disabled or removed. Removal scans ownership
|
||||
while holding the registry then accounts locks, refuses if the account owns an
|
||||
organization (including archived organizations) or any registry entry lacks its
|
||||
owner metadata during creation, and removes its memberships in
|
||||
the same write. List reports rollback rows needing repair; its JSON omits invite
|
||||
bindings and hashes. Receipts record effective process uid and verb and omit subjects and secrets.
|
||||
@@ -0,0 +1,109 @@
|
||||
// Phase M2: the local, instance-uid account administration exception. No org
|
||||
// rights commands and no bus transport: account init must work before orgs exist.
|
||||
import { basename, join } from 'node:path';
|
||||
import { configDir } from '../../organization/src/organization-config.mjs';
|
||||
import { accountsRepairs, assertAccountsOwner, loadAccounts, mintAccountId, withAccounts } from '../../organization/src/accounts.mjs';
|
||||
import { withRegistry, readRegistryLocked } from '../../organization/src/org-registry.mjs';
|
||||
import { readJsonFile, rejectUnknownKeys, requireString } from '../../organization/src/util.mjs';
|
||||
import { CliError } from './errors.mjs';
|
||||
import { refuseInsideAgent } from './transport.mjs';
|
||||
|
||||
export const ACCOUNTS_USAGE = [
|
||||
'mosaic accounts init [--name NAME]',
|
||||
'mosaic accounts list [--json]',
|
||||
'mosaic accounts user add --name NAME --subject PROVIDER:SUB [--role operator|user] --revision N',
|
||||
'mosaic accounts user disable|enable|signout|remove ID --revision N',
|
||||
].join('\n');
|
||||
function parse(args) {
|
||||
const words = [], values = {}, flags = new Set();
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (!arg.startsWith('--')) { words.push(arg); continue; }
|
||||
if (flags.has(arg) || Object.hasOwn(values, arg)) throw new CliError(`duplicate ${arg}`, 4);
|
||||
if (arg === '--tester') throw new CliError('refused: accounts takes the human shell, not --tester', 3);
|
||||
if (arg === '--json') flags.add(arg);
|
||||
else if (['--name', '--subject', '--role', '--revision'].includes(arg) && args[i + 1] && !args[i + 1].startsWith('--')) values[arg] = args[++i];
|
||||
else throw new CliError(ACCOUNTS_USAGE, 4);
|
||||
}
|
||||
const verb = words[0] === 'user' ? `user.${words[1]}` : words[0];
|
||||
const valid = ['init', 'list'].includes(verb) ? words.length === 1 : verb === 'user.add' ? words.length === 2 : ['user.disable', 'user.enable', 'user.signout', 'user.remove'].includes(verb) && words.length === 3;
|
||||
if (!valid) throw new CliError(ACCOUNTS_USAGE, 4);
|
||||
const allowed = verb === 'init' ? ['--name'] : verb === 'list' ? [] : verb === 'user.add' ? ['--name', '--subject', '--role', '--revision'] : ['--revision'];
|
||||
if (Object.keys(values).some(k => !allowed.includes(k)) || (flags.size && verb !== 'list')) throw new CliError(ACCOUNTS_USAGE, 4);
|
||||
const revision = verb === 'init' ? 0 : Number(values['--revision']);
|
||||
if (verb !== 'list' && verb !== 'init' && (!/^[1-9][0-9]*$/.test(values['--revision'] ?? '') || !Number.isSafeInteger(revision))) throw new CliError('--revision requires the revision from accounts list', 4);
|
||||
return { verb, values, flags, revision, id: words[2] };
|
||||
}
|
||||
function owns(user, owner, providers) {
|
||||
return user.subjects.some(s => s.provider === owner.provider && s.sub === owner.sub && providers.find(p => p.id === s.provider)?.issuer === owner.iss);
|
||||
}
|
||||
function readOwner(root) {
|
||||
const owner = readJsonFile(join(root, 'owner.json'), 'organization owner', st => {
|
||||
if (st.uid !== process.geteuid() || (st.mode & 0o777) !== 0o600) throw new CliError('refused: unsafe organization owner', 3);
|
||||
});
|
||||
rejectUnknownKeys(owner, ['provider', 'iss', 'sub'], 'organization owner');
|
||||
for (const key of ['provider', 'iss', 'sub']) requireString(owner[key], `owner.${key}`, { max: 2048 });
|
||||
return owner;
|
||||
}
|
||||
export async function accountsCommand(args, io, deps) {
|
||||
refuseInsideAgent(io.env);
|
||||
try {
|
||||
const parsed = parse(args);
|
||||
const dir = deps.configDir ?? configDir(io.env);
|
||||
// This is deliberately before withRegistry as well as withAccounts.
|
||||
assertAccountsOwner(dir, { uid: deps.uid ?? process.geteuid() });
|
||||
const providers = deps.system.console?.sso?.providers ?? [];
|
||||
const ctx = { configDir: dir, instance: deps.instance ?? basename(dir).replace(/^mosaic-/, ''), providers };
|
||||
if (parsed.verb === 'list') {
|
||||
const doc = loadAccounts(join(dir, 'accounts.json'), ctx);
|
||||
const repairs = accountsRepairs(doc);
|
||||
// Invites are not administered here. Never expose bindings or token hashes.
|
||||
const listed = { ...doc, invites: doc.invites.map(({ tokenHash, bind, ...invite }) => invite), repairs };
|
||||
io.stdout.write(parsed.flags.has('--json') ? JSON.stringify(listed, null, 2) + '\n' : [`accounts revision ${doc.revision}`, ...doc.users.map(u => `${u.id} ${u.name} ${u.instanceRole} ${u.state} authGen ${u.authGen}`), ...repairs.map(r => `${r.kind} ${r.org} needs repair: ${[r.unknownRole, ...r.unknownRights].filter(Boolean).join(', ')}`)].join('\n') + '\n');
|
||||
return;
|
||||
}
|
||||
let added;
|
||||
const mutate = doc => {
|
||||
if (parsed.verb === 'init') {
|
||||
const subjects = providers.flatMap(p => (p.subjects ?? []).map(s => ({ provider: p.id, sub: s.sub })));
|
||||
if (!subjects.length) throw new CliError('refused: init requires console.sso subjects', 3);
|
||||
added = { id: mintAccountId(), name: parsed.values['--name'] ?? 'Operator', subjects, instanceRole: 'operator', state: 'active', authGen: 1, created: new Date().toISOString() };
|
||||
doc.users.push(added); return;
|
||||
}
|
||||
if (parsed.verb === 'user.add') {
|
||||
const value = parsed.values['--subject'] ?? '', colon = value.indexOf(':');
|
||||
if (!parsed.values['--name'] || colon < 1 || colon === value.length - 1) throw new CliError('user add needs --name and --subject PROVIDER:SUB', 4);
|
||||
added = { id: mintAccountId(), name: parsed.values['--name'], subjects: [{ provider: value.slice(0, colon), sub: value.slice(colon + 1) }], instanceRole: parsed.values['--role'] ?? 'user', state: 'active', authGen: 1, created: new Date().toISOString() };
|
||||
doc.users.push(added); return;
|
||||
}
|
||||
const u = doc.users.find(u => u.id === parsed.id);
|
||||
if (!u) throw new CliError('refused: account not found', 2);
|
||||
if (parsed.verb === 'user.remove') { doc.users = doc.users.filter(x => x !== u); doc.memberships = doc.memberships.filter(m => m.user !== u.id); }
|
||||
else if (parsed.verb === 'user.signout') u.authGen++;
|
||||
else { const state = parsed.verb === 'user.disable' ? 'disabled' : 'active'; if (u.state === state) throw new CliError('refused: account already has that state', 2); u.state = state; }
|
||||
};
|
||||
const opts = { revision: parsed.revision, verb: parsed.verb, init: parsed.verb === 'init' };
|
||||
let result;
|
||||
if (parsed.verb === 'user.remove') {
|
||||
result = await withRegistry(ctx, registry => withAccounts(ctx, { ...opts, registry }, async doc => {
|
||||
const user = doc.users.find(u => u.id === parsed.id);
|
||||
if (!user) throw new CliError('refused: account not found', 2);
|
||||
// Even disabled accounts keep ownership. A real create reserves before
|
||||
// writing owner.json, so any missing owner must block removal too.
|
||||
for (const entry of Object.values((await readRegistryLocked(registry)).orgs)) {
|
||||
let owner;
|
||||
try { owner = readOwner(entry.root); }
|
||||
catch (e) { if (/not found/.test(e.message)) throw new CliError(`refused: org-create-in-progress: ${entry.id} has no owner metadata`, 3); throw e; }
|
||||
if (owns(user, owner, providers)) throw new CliError(`refused: user-owns-org: ${entry.id}`, 3);
|
||||
}
|
||||
mutate(doc);
|
||||
}), { write: false });
|
||||
} else result = await withAccounts(ctx, opts, mutate);
|
||||
io.stdout.write(`accounts ${parsed.verb.replace('.', ' ')}: ${added?.id ?? parsed.id ?? ''}, revision ${result.revision}\n`);
|
||||
} catch (e) {
|
||||
if (e instanceof CliError) throw e;
|
||||
const error = new CliError(`refused: ${e.code ? e.code + ': ' : ''}${e.message}${e.committed ? '; accounts write committed; durability or receipt step failed; inspect before retrying' : ''}`, 3);
|
||||
if (e.committed) error.committed = true;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// #1873 Phase A slice A2: the owner's terminal verbs for the root and project agent levels (design
|
||||
// docs/plans/2026-11-10_agent-levels-design.md sections 6, 7 and 9 item 1).
|
||||
//
|
||||
// mosaic agents root list [--json]
|
||||
// mosaic agents root set NAME [--definition ROLE] [--var KEY=VALUE]... [--var-json KEY=JSON]...
|
||||
// [--unset-var KEY]... [--exclude-orgs ID,ID | --exclude-orgs none] [--revision R]
|
||||
// mosaic agents root remove NAME [--revision R]
|
||||
// mosaic project agents PROJECT --org ID list [--json]
|
||||
// mosaic project agents PROJECT --org ID set NAME --definition ROLE [--var KEY=VALUE]... [--var-json KEY=JSON]... [--revision R]
|
||||
// mosaic project agents PROJECT --org ID narrow NAME --var KEY=VALUE... [--var-json KEY=JSON]... [--revision R]
|
||||
// mosaic project agents PROJECT --org ID exclude NAME [--revision R]
|
||||
// mosaic project agents PROJECT --org ID remove NAME [--revision R]
|
||||
//
|
||||
// Every write is the owner's: an agent run is refused before anything is read (exit 3), and the
|
||||
// verbs run only on a named dev instance, as `mosaic org` does, with the local dev owner as actor.
|
||||
// Without --revision a write reads the revision just before it writes, so a concurrent change
|
||||
// between that read and the write still refuses, but one made before the command started does
|
||||
// not. Pass --revision from a `list` to make the write conditional on what was listed.
|
||||
// A write prints its receipt; refusals name keys, never values.
|
||||
|
||||
import { configDir } from '../../organization/src/organization-config.mjs';
|
||||
import { OrgError } from '../../organization/src/org-registry.mjs';
|
||||
import { OrganizationError, organizationWriter } from '../../organization/src/organization.mjs';
|
||||
import { changeRootAgent, patchRootEntry, projectAgentSummary, readRootAgentsFile, rootAgentSummary } from '../../organization/src/agent-levels-write.mjs';
|
||||
import { loadSystem, rolesDir } from './config.mjs';
|
||||
import { DEV_OWNER, devOwnerInstance } from './dev-local-owner.mjs';
|
||||
import { CliError } from './errors.mjs';
|
||||
import { refuseInsideAgent } from './transport.mjs';
|
||||
|
||||
export const AGENTS_ROOT_USAGE = 'mosaic agents root list [--json] | set NAME [--definition ROLE] [--var KEY=VALUE]... [--var-json KEY=JSON]... [--unset-var KEY]... [--exclude-orgs ID,ID|none] [--revision R] | remove NAME [--revision R]';
|
||||
export const PROJECT_AGENTS_USAGE = 'mosaic project agents PROJECT --org ID list [--json] | set NAME --definition ROLE [--var KEY=VALUE]... [--revision R] | narrow NAME --var KEY=VALUE... [--revision R] | exclude NAME [--revision R] | remove NAME [--revision R]';
|
||||
|
||||
const usage = (text, line) => new CliError(`${text}\nusage: ${line}`, 4);
|
||||
const REPEATED = ['--var', '--var-json', '--unset-var'];
|
||||
const SINGLE = ['--definition', '--exclude-orgs', '--revision', '--org'];
|
||||
|
||||
// Option values are never echoed back: a refusal names the option only.
|
||||
function parse(args, line) {
|
||||
const positional = [], single = {}, repeated = { '--var': [], '--var-json': [], '--unset-var': [] }, flags = new Set();
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a === '--json') { if (flags.has(a)) throw usage('duplicate --json', line); flags.add(a); }
|
||||
else if (REPEATED.includes(a) || SINGLE.includes(a)) {
|
||||
if (i + 1 >= args.length) throw usage(`${a} needs one value`, line);
|
||||
const value = args[++i];
|
||||
if (REPEATED.includes(a)) repeated[a].push(value);
|
||||
else if (Object.hasOwn(single, a)) throw usage(`duplicate ${a}`, line);
|
||||
else single[a] = value;
|
||||
} else if (a.startsWith('--')) throw usage('unknown option', line);
|
||||
else positional.push(a);
|
||||
}
|
||||
return { positional, single, repeated, flags };
|
||||
}
|
||||
|
||||
function only(parsed, allowed, line) {
|
||||
for (const key of [...Object.keys(parsed.single), ...REPEATED.filter(k => parsed.repeated[k].length), ...parsed.flags])
|
||||
if (!allowed.includes(key)) throw usage(`unexpected ${key}`, line);
|
||||
}
|
||||
|
||||
// KEY=VALUE pairs; a --var-json value is parsed as JSON. A malformed pair names its key at most.
|
||||
function vars(parsed, line) {
|
||||
const out = {};
|
||||
const put = (key, value) => {
|
||||
if (!key) throw usage('a var needs KEY=VALUE', line);
|
||||
if (Object.hasOwn(out, key)) throw usage(`var ${key} is given twice`, line);
|
||||
out[key] = value;
|
||||
};
|
||||
for (const pair of parsed.repeated['--var']) {
|
||||
const at = pair.indexOf('=');
|
||||
if (at < 1) throw usage('a var needs KEY=VALUE', line);
|
||||
put(pair.slice(0, at), pair.slice(at + 1));
|
||||
}
|
||||
for (const pair of parsed.repeated['--var-json']) {
|
||||
const at = pair.indexOf('=');
|
||||
if (at < 1) throw usage('a var needs KEY=JSON', line);
|
||||
const key = pair.slice(0, at);
|
||||
try { put(key, JSON.parse(pair.slice(at + 1))); }
|
||||
catch (e) { if (e instanceof CliError) throw e; throw usage(`var ${key} is not valid JSON`, line); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const revisionOf = (parsed, read) => parsed.single['--revision'] ?? read();
|
||||
function owner(io) {
|
||||
refuseInsideAgent(io.env);
|
||||
// Its refusal is one fixed line naming no path.
|
||||
let instance;
|
||||
try { instance = devOwnerInstance(io.env); } catch (error) { throw error instanceof CliError ? error : new CliError(String(error?.message), 2); }
|
||||
// The instance's system config must exist and validate, marker included, as `mosaic org` checks:
|
||||
// a missing or broken config refuses (exit 3) before anything is read or written.
|
||||
loadSystem({ env: io.env, instance });
|
||||
return { dir: configDir(io.env), roles: rolesDir(io.env) };
|
||||
}
|
||||
// Only the writers' own coded refusals print their text (it names keys and ids, never a value).
|
||||
// Anything else, a filesystem error above all, prints its code and a fixed line: its message can
|
||||
// hold a host path. So does a coded refusal whose text happens to hold an absolute path.
|
||||
const ABSOLUTE_PATH = /(?:^|[\s('"=:])\/[^\s/]/;
|
||||
function refusal(error) {
|
||||
if (error instanceof CliError) return error;
|
||||
const code = typeof error?.code === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(error.code) ? error.code : null;
|
||||
const ours = (error instanceof OrgError || error instanceof OrganizationError || error?.name === 'BusinessError') && code !== null;
|
||||
const text = ours && typeof error.message === 'string' && !ABSOLUTE_PATH.test(error.message) ? error.message : 'the operation failed; the error text is not shown';
|
||||
// A refusal after the replace says the change landed; the receipt may be missing.
|
||||
const landed = error?.committed ? ' (the change was written; its receipt may be missing)' : '';
|
||||
return new CliError(`${code ? `${code}: ` : ''}${text}${landed}`, 2);
|
||||
}
|
||||
|
||||
export async function agentsRootCommand(args, io) {
|
||||
const line = AGENTS_ROOT_USAGE;
|
||||
const parsed = parse(args, line);
|
||||
const [op, name, ...extra] = parsed.positional;
|
||||
try {
|
||||
const { dir, roles } = owner(io);
|
||||
if (op === 'list') {
|
||||
if (name !== undefined) throw usage('list takes no name', line);
|
||||
only(parsed, ['--json'], line);
|
||||
const read = await readRootAgentsFile(dir);
|
||||
const listing = { revision: read.revision, state: read.exists ? 'present' : 'absent', agents: rootAgentSummary(read.document) };
|
||||
io.stdout.write(parsed.flags.has('--json') ? `${JSON.stringify(listing, null, 2)}\n` : formatRoot(listing));
|
||||
return;
|
||||
}
|
||||
if (!['set', 'remove'].includes(op) || !name || extra.length) throw usage('root takes list, set NAME or remove NAME', line);
|
||||
only(parsed, op === 'set' ? ['--definition', '--var', '--var-json', '--unset-var', '--exclude-orgs', '--revision'] : ['--revision'], line);
|
||||
let entry = null;
|
||||
if (op === 'set') {
|
||||
const patch = {};
|
||||
if (Object.hasOwn(parsed.single, '--definition')) patch.definition = parsed.single['--definition'];
|
||||
const set = vars(parsed, line);
|
||||
for (const key of parsed.repeated['--unset-var']) {
|
||||
if (Object.hasOwn(set, key)) throw usage(`var ${key} is both set and unset`, line);
|
||||
set[key] = null;
|
||||
}
|
||||
if (Object.keys(set).length) patch.vars = set;
|
||||
if (Object.hasOwn(parsed.single, '--exclude-orgs')) {
|
||||
const text = parsed.single['--exclude-orgs'];
|
||||
patch.excludeOrgs = text === 'none' ? [] : text.split(',');
|
||||
}
|
||||
if (!Object.keys(patch).length) throw usage('set needs --definition, a var or --exclude-orgs', line);
|
||||
entry = old => patchRootEntry(old, patch);
|
||||
}
|
||||
const revision = await revisionOf(parsed, async () => (await readRootAgentsFile(dir)).revision);
|
||||
const receipt = await changeRootAgent(dir, { revision, name, entry }, { actor: DEV_OWNER, owner: DEV_OWNER, rolesDir: roles });
|
||||
io.stdout.write(`${JSON.stringify(receipt)}\n`);
|
||||
} catch (error) { throw refusal(error); }
|
||||
}
|
||||
|
||||
export async function projectAgentsCommand(args, io) {
|
||||
const line = PROJECT_AGENTS_USAGE;
|
||||
const parsed = parse(args, line);
|
||||
const [project, op, name, ...extra] = parsed.positional;
|
||||
try {
|
||||
const { dir, roles } = owner(io);
|
||||
const org = parsed.single['--org'];
|
||||
if (!project || !org) throw usage('project agents needs PROJECT and --org ID', line);
|
||||
const writer = organizationWriter({ dir, rolesDir: roles });
|
||||
if (op === 'list') {
|
||||
if (name !== undefined) throw usage('list takes no name', line);
|
||||
only(parsed, ['--org', '--json'], line);
|
||||
const { document, revision } = writer.read(org);
|
||||
if (!Object.hasOwn(document.projects ?? {}, project)) throw new OrgError('project-not-found', `project ${project} is not in organization ${org}`);
|
||||
const listing = { organization: org, project, revision, agents: projectAgentSummary(document.projects[project].agents ?? {}) };
|
||||
io.stdout.write(parsed.flags.has('--json') ? `${JSON.stringify(listing, null, 2)}\n` : formatProject(listing));
|
||||
return;
|
||||
}
|
||||
if (!['set', 'narrow', 'exclude', 'remove'].includes(op) || !name || extra.length) throw usage('project agents takes list, set, narrow, exclude or remove NAME', line);
|
||||
const allowed = { set: ['--definition', '--var', '--var-json'], narrow: ['--var', '--var-json'], exclude: [], remove: [] }[op];
|
||||
only(parsed, ['--org', '--revision', ...allowed], line);
|
||||
let entry = null;
|
||||
if (op === 'set') {
|
||||
if (!Object.hasOwn(parsed.single, '--definition')) throw usage('set needs --definition', line);
|
||||
const v = vars(parsed, line);
|
||||
entry = { definition: parsed.single['--definition'], ...(Object.keys(v).length ? { vars: v } : {}) };
|
||||
} else if (op === 'narrow') {
|
||||
const v = vars(parsed, line);
|
||||
if (!Object.keys(v).length) throw usage('narrow needs at least one --var', line);
|
||||
entry = { vars: v };
|
||||
} else if (op === 'exclude') entry = { exclude: true };
|
||||
const revisionBefore = revisionOf(parsed, () => writer.read(org).revision);
|
||||
const result = writer.setProjectAgent(org, { revision: revisionBefore, project, name, entry }, DEV_OWNER.sub);
|
||||
// The receipt: names and revisions only. The writer's `previous` is a host path; it isn't printed.
|
||||
const receipt = { verb: 'project.agents.set', organization: org, project, agent: name, form: op === 'set' ? 'define' : op, revisionBefore, revisionAfter: result.revision, transaction: result.transaction };
|
||||
io.stdout.write(`${JSON.stringify(receipt)}\n`);
|
||||
} catch (error) { throw refusal(error); }
|
||||
}
|
||||
|
||||
function formatRoot({ revision, state, agents }) {
|
||||
if (!agents.length) return `root agents: none (${state === 'absent' ? 'no file' : 'empty'}), revision ${revision}\n`;
|
||||
return `root agents (revision ${revision}):\n` + agents.map(a =>
|
||||
` ${a.name}: ${a.definition}${a.model ? `, model ${a.model}` : ''}${a.varKeys.length ? `, vars ${a.varKeys.join(', ')}` : ''}${a.excludeOrgs.length ? `, not in ${a.excludeOrgs.join(', ')}` : ''}\n`).join('');
|
||||
}
|
||||
function formatProject({ organization, project, revision, agents }) {
|
||||
if (!agents.length) return `project ${project} in ${organization}: no project agent entries, revision ${revision}\n`;
|
||||
return `project ${project} in ${organization} (revision ${revision}):\n` + agents.map(a =>
|
||||
` ${a.name}: ${a.form}${a.definition ? ` ${a.definition}` : ''}${a.varKeys?.length ? `, vars ${a.varKeys.join(', ')}` : ''}\n`).join('');
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import { hostsCommand, HOSTS_USAGE } from "./hosts.mjs";
|
||||
import { grantsCommand, GRANTS_USAGE } from "./grants.mjs";
|
||||
import { instancesCommand, orgInstancesCommand, ORG_INSTANCES_USAGE } from "./instances.mjs";
|
||||
import { accessCommand, accessContextFor } from "./access.mjs";
|
||||
import { accountsCommand, ACCOUNTS_USAGE } from "./accounts.mjs";
|
||||
import { createChannelCaller } from "../../plugins/src/channel.mjs";
|
||||
import { PluginError } from "../../plugins/src/kinds.mjs";
|
||||
import { CliError, CliExit } from "./errors.mjs";
|
||||
@@ -47,11 +48,13 @@ import { consoleRestart, consoleStart, consoleStatus, consoleStop } from "./org-
|
||||
|
||||
import { usageCommand, USAGE_USAGE } from "./usage.mjs";
|
||||
import { orgCommand, ORG_USAGE } from "./org.mjs";
|
||||
import { agentsRootCommand, AGENTS_ROOT_USAGE, projectAgentsCommand, PROJECT_AGENTS_USAGE } from "./agent-levels.mjs";
|
||||
import { ORG_CONFIG, runConfigCommand } from "../../organization/src/config-command.mjs";
|
||||
import { devCommand, DEV_USAGE, fixtureLaunchEnv, fixtureLauncherOptions } from "./dev-fixture.mjs";
|
||||
import { runCommand, RUN_USAGE } from "./run-evidence.mjs";
|
||||
|
||||
export const USAGE = `usage:
|
||||
${ACCOUNTS_USAGE}
|
||||
${ORG_USAGE}
|
||||
${USAGE_USAGE}
|
||||
${LIMITS_USAGE}
|
||||
@@ -63,6 +66,8 @@ export const USAGE = `usage:
|
||||
${ORG_INSTANCES_USAGE}
|
||||
mosaic business validate|resolve|instances|instance|veto: the old spelling of mosaic org config validate|resolve and mosaic org instances|instance|veto, same arguments
|
||||
${PROJECT_USAGE}
|
||||
${PROJECT_AGENTS_USAGE}
|
||||
${AGENTS_ROOT_USAGE}
|
||||
${PROJECT_BUS_USAGE}
|
||||
${NORTH_STAR_USAGE}
|
||||
${INTERVIEW_USAGE}
|
||||
@@ -653,7 +658,7 @@ async function bus(rest, io, deps) {
|
||||
// tracker gets (the bus runtime makes the same choice, runtime.mjs trackerOf).
|
||||
const taskModule = boot.businesses?.[businessId]?.plugins?.["task-tracker"];
|
||||
const tracker = Boolean(boot.trackers?.[businessId]) || taskModule === undefined || taskModule === "store";
|
||||
const launcher = createLauncher({ host, system: { ...system, instanceRoot: system.dataRoot, dataRoot: org.root }, env: launchEnv, tracker, launches, ...(instance ? fixtureLauncherOptions({ system, env: launchEnv, instance, doc: orgDoc }) : {}), ...(deps.launcher ?? {}) });
|
||||
const launcher = createLauncher({ host, system: { ...system, instanceRoot: system.dataRoot, dataRoot: org.root }, env: launchEnv, tracker, launches, copies: boot.copies ?? null, ...(instance ? fixtureLauncherOptions({ system, env: launchEnv, instance, doc: orgDoc }) : {}), ...(deps.launcher ?? {}) });
|
||||
await launcher.listen();
|
||||
if (instance) io.stdout.write(`DEV LOCAL OWNER MODE: console owner local-dev accepted (named instance ${instance})\n`);
|
||||
if (instance && parsed.flags.has("--pm")) io.stdout.write('DEV LOCAL OWNER MODE: launched agents run as this OS user and can reach the loopback Console as owner; use this instance for UI testing only\n');
|
||||
@@ -729,6 +734,9 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
|
||||
if (verb === 'org' && rest[0] === 'config') return orgConfig(rest.slice(1), io);
|
||||
if (verb === 'org' && ["instances", "instance", "veto"].includes(rest[0])) return orgInstancesCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
if (verb === 'org') return orgCommand(rest, io, deps);
|
||||
// A2 (#1873): the owner's root and project agent levels. `mosaic agents` without `root` stays the bus read.
|
||||
if (verb === "agents" && rest[0] === "root") return agentsRootCommand(rest.slice(1), io);
|
||||
if (verb === "project" && rest[0] === "agents") return projectAgentsCommand(rest.slice(1), io);
|
||||
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
|
||||
if (verb === "project" && rest[0] === "north-star") return northStarCommand(rest.slice(1), io, (p) => callFor(p, io, deps));
|
||||
if (verb === "project" && rest[0] === "interview") return interviewCommand(rest.slice(1), io);
|
||||
@@ -745,6 +753,7 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
|
||||
now: deps.now,
|
||||
});
|
||||
}
|
||||
if (verb === 'accounts') return accountsCommand(rest, io, { system: deps.system ?? loadSystem({ env: io.env }), configDir: deps.accountsConfigDir });
|
||||
if (verb === "limits") return limitsCommand(rest,io,p=>callFor(p,io,deps));
|
||||
if (verb === "usage") return usageCommand(rest, io, p => callFor(p, io, deps));
|
||||
if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
|
||||
@@ -10,6 +10,10 @@ import { dirname, isAbsolute, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { managedState } from "../../organization/src/project-bootstrap.mjs";
|
||||
import { BusinessError, accessFilePath, configDir, loadAccess, loadBusiness, loadProject, projectFilePath, resolveInstance, systemVars } from "../../organization/src/index.mjs";
|
||||
import { loadRootAgents } from "../../organization/src/agent-levels.mjs";
|
||||
import { materializeCopies, withCopies } from "../../organization/src/agent-copies.mjs";
|
||||
import { organizationWriter } from "../../organization/src/organization.mjs";
|
||||
import { businessFilePath } from "../../organization/src/organization-config.mjs";
|
||||
import { busOrganization } from "../../bus/src/organization-context.mjs";
|
||||
import { BusError } from "../../bus/src/broker.mjs";
|
||||
import { CliError } from "./errors.mjs";
|
||||
@@ -86,7 +90,7 @@ function trackerFor(system, b, warn, dataRoot) {
|
||||
if (dataRoot && managedState({ dataRoot, business: b.id, project: id, entry }) === 'reserved') continue;
|
||||
if (!existsSync(projectFilePath(entry.root))) continue;
|
||||
const project = loadProject(entry.root);
|
||||
if (project.id !== id) throw new CliError(`project file ${project.file} has id ${project.id}, but business ${b.id} declares it as ${id}`, 3);
|
||||
if (project.id !== id) throw new CliError(`project file ${project.file} has id that does not match the declared project key`, 3);
|
||||
const vars = resolveInstance({ system, business: b, project, instance: first }).vars;
|
||||
if (vars["tracker.project"] !== undefined) named.push({ id, vars });
|
||||
}
|
||||
@@ -144,10 +148,29 @@ export const consoleOwners = (sso) =>
|
||||
// The `{op: 'boot'}` config for packages/bus/src/process.mjs, for one
|
||||
// business. The host holds a reader capability for the notifier and binds
|
||||
// launches later through bindLaunch, so `launches` starts empty.
|
||||
// #1873 A3: the organization's root and project agent copies, read once here. The root file is read
|
||||
// through loadRootAgents and the organization file through the organization's own writer; the
|
||||
// snapshot is plain data that the broker and the launcher re-apply to every later read of the
|
||||
// organization file, so a root file change takes effect at the next boot only. A root file that
|
||||
// refuses hides the root level (design section 6): no root copy, and the organization still boots.
|
||||
export function copiesAtBoot({ businessId, env = process.env, warn = () => {} }) {
|
||||
const dir = configDir(env), roles = rolesDir(env);
|
||||
loadBusiness(businessId, { dir, rolesDir: roles });
|
||||
const { document } = organizationWriter({ dir, rolesDir: roles }).read(businessId);
|
||||
let root = null;
|
||||
try { root = loadRootAgents(dir, { rolesDir: roles }); }
|
||||
catch (error) {
|
||||
if (!(error instanceof BusinessError)) throw error;
|
||||
warn(`root agent file refused, so ${businessId} runs no root copies until it is fixed and the bus restarts: ${error.message}`);
|
||||
}
|
||||
const snapshot = materializeCopies({ orgId: businessId, doc: document, root, rolesDir: roles });
|
||||
return { snapshot, business: withCopies(document, businessFilePath(businessId, dir), { rolesDir: roles, snapshot }) };
|
||||
}
|
||||
|
||||
export function bootConfig({ system, businessId, orgRoot = system.dataRoot, env = process.env, warn = () => {} }) {
|
||||
return business(() => {
|
||||
const vars = systemVars(system);
|
||||
const b = loadBusiness(businessId, { dir: configDir(env), rolesDir: rolesDir(env) });
|
||||
const { snapshot, business: b } = copiesAtBoot({ businessId, env, warn });
|
||||
const resolved = {};
|
||||
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: vars, business: b, instance });
|
||||
const config = {
|
||||
@@ -158,6 +181,8 @@ export function bootConfig({ system, businessId, orgRoot = system.dataRoot, env
|
||||
organization: {dir:configDir(env),rolesDir:rolesDir(env),system:vars},
|
||||
launches: [],
|
||||
readers: [b.id],
|
||||
// Present only when the organization has a copy, so a boot with none is the boot it was.
|
||||
...(Object.keys(snapshot.copies).length ? { copies: snapshot } : {}),
|
||||
};
|
||||
const tracker = trackerFor(vars, b, warn, orgRoot);
|
||||
if (tracker) {
|
||||
|
||||
@@ -143,9 +143,10 @@ import { createHash } from 'node:crypto';
|
||||
import { watchModelOutput } from './model-stall.mjs';
|
||||
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, relative } from "node:path";
|
||||
import { configDir, loadBusiness, resolveInstance, systemVars } from "../../organization/src/index.mjs";
|
||||
import { configDir, resolveInstance, systemVars } from "../../organization/src/index.mjs";
|
||||
import { organizationWriter } from "../../organization/src/organization.mjs";
|
||||
import { businessFilePath, validateBusinessDocument } from "../../organization/src/organization-config.mjs";
|
||||
import { businessFilePath } from "../../organization/src/organization-config.mjs";
|
||||
import { copyOf, copyRefusal, loadWithCopies, projectRefusal, withCopies } from "../../organization/src/agent-copies.mjs";
|
||||
import { selectModelProvider } from "../../plugins/src/model-provider-selection.mjs";
|
||||
import { THREAD_DEFINITIONS } from "../../organization/src/vocabulary.mjs";
|
||||
import { limitHit } from "../../organization/src/launch.mjs";
|
||||
@@ -244,6 +245,9 @@ export function createLauncher({
|
||||
git = {},
|
||||
stopOptions = {},
|
||||
log = (l) => process.stderr.write(`mosaic-bus: ${l}\n`),
|
||||
// #1873 A3: the root and project agent copies the bus booted with (config.mjs copiesAtBoot), or
|
||||
// null. Every read of the organization file here re-applies this snapshot, never the root file.
|
||||
copies = null,
|
||||
}) {
|
||||
if (testProxy !== null && system.proxy)
|
||||
throw Object.assign(new Error("testProxy skips P2 admission, so it is refused with system.proxy set"), { code: "test-proxy-refused" });
|
||||
@@ -257,7 +261,8 @@ export function createLauncher({
|
||||
}
|
||||
// Row 64 (#1557): where a role's skills are read from; scripts/skill.sh activate fills it.
|
||||
const skillsDir = join(system.instanceRoot ?? dataRoot, "skills-enabled");
|
||||
let b = loadBusiness(host.business, { dir: configDir(env), rolesDir: rolesDir(env) });
|
||||
const loadOrg = () => loadWithCopies(host.business, { dir: configDir(env), rolesDir: rolesDir(env), snapshot: copies?.org === host.business ? copies : null });
|
||||
let b = loadOrg();
|
||||
const vars = systemVars(system);
|
||||
|
||||
// Sessions an earlier host left in sessions.json: it died without ending
|
||||
@@ -355,10 +360,17 @@ export function createLauncher({
|
||||
try {
|
||||
const snapshot = organizationWriter({dir:configDir(env),rolesDir:rolesDir(env)}).read(host.business);
|
||||
businessRevision = snapshot.revision;
|
||||
b = validateBusinessDocument(snapshot.document, businessFilePath(host.business, configDir(env)), {rolesDir:rolesDir(env)});
|
||||
b = withCopies(snapshot.document, businessFilePath(host.business, configDir(env)), {rolesDir:rolesDir(env), snapshot: copies?.org === host.business ? copies : null});
|
||||
} catch(e){throw new Refusal("organization-refused",e.message);}
|
||||
if (!b.launch) throw new Refusal("launch-not-configured", `business ${b.id} has no launch block`);
|
||||
if (!(pm ? instance === b.launch.by : b.launch.instances.includes(instance) || (human && Object.hasOwn(b.roles, instance)))) throw new Refusal("instance-not-listed");
|
||||
// #1873 A3: an unbound or refused copy never launches. The message names the copy, its project
|
||||
// and the missing service names or the refusal, never a value.
|
||||
const copyRefused = copyRefusal(b, instance);
|
||||
if (copyRefused) throw new Refusal(copyRefused.code, copyRefused.message);
|
||||
// #1873 A3: a bound copy isn't on the chief's list. It launches when the human asks, or for a
|
||||
// thread, which only a human or a granted user makes, naming the copy.
|
||||
const boundCopy = copyOf(b, instance)?.state === "instance" && Object.hasOwn(b.roles, instance);
|
||||
if (!(pm ? instance === b.launch.by : b.launch.instances.includes(instance) || (human && Object.hasOwn(b.roles, instance)) || (thread && boundCopy))) throw new Refusal("instance-not-listed");
|
||||
const threaded = THREAD_DEFINITIONS.includes(b.roles[instance]?.definition);
|
||||
if (threaded && !thread) throw new Refusal("thread-required", `${instance} runs only for a thread`);
|
||||
if (!threaded && thread) throw new Refusal("thread-not-allowed", `${instance} isn't a thread instance`);
|
||||
@@ -539,12 +551,12 @@ export function createLauncher({
|
||||
await pickRoute(() => standIn);
|
||||
await gate(model, harness, null);
|
||||
admission(admissionGeneration);
|
||||
return { resolved, model, account: null, harness, route, routeReason, admissionGeneration, resolver };
|
||||
return { resolved, model, account: null, harness, route, routeReason, admissionGeneration, resolver, revision: businessRevision };
|
||||
}
|
||||
const sessionProxy = await pickRoute(proxyFor);
|
||||
const account = await gate(model, harness, sessionProxy);
|
||||
admission(admissionGeneration);
|
||||
return { resolved, model, account, harness, route, routeReason, admissionGeneration, sessionProxy, resolver };
|
||||
return { resolved, model, account, harness, route, routeReason, admissionGeneration, sessionProxy, resolver, revision: businessRevision };
|
||||
}
|
||||
|
||||
// Reread host state on every new-run path, including opens/clears queued before archive.
|
||||
@@ -554,6 +566,8 @@ export function createLauncher({
|
||||
const row = rows.find(r => r.thread === thread && r.session === session);
|
||||
if (!row) throw new Refusal("session-not-found");
|
||||
if (row.archived) throw new Refusal("project-archived");
|
||||
// #1873 A3: the thread's project no longer takes its instance: the union refuses, or excludes it.
|
||||
if (row.projectRefused) throw new Refusal(row.projectRefused.code, row.projectRefused.reason);
|
||||
}
|
||||
|
||||
// Bundle, spawn, bind, register, hand over the capability.
|
||||
@@ -616,13 +630,24 @@ export function createLauncher({
|
||||
// (a mapping published before the seed and records finished) is left out and the launch goes on without
|
||||
// it. Ordinary mappings also recheck archive state through the same host admission.
|
||||
const codeProjects = {};
|
||||
// #1873 A3: an instance gets only the code projects it works in: a project agent its own one, and
|
||||
// no instance a project that excludes it or whose union refuses. A project agent refuses when its
|
||||
// own project isn't admitted. A checkout an earlier launch left in the workspace for a project
|
||||
// the instance may not work in now is hidden from the session, never deleted (sandbox.mjs).
|
||||
const own = copyOf(b, instance)?.project ?? null;
|
||||
const declared = new Set();
|
||||
for (const [project, entry] of Object.entries(b.projects)) {
|
||||
if (entry.kind === 'library') continue;
|
||||
declared.add(project);
|
||||
if (own !== null && project !== own) continue;
|
||||
const unavailable = projectRefusal(b, instance, project);
|
||||
if (unavailable) { record({ event: "checkout-skipped", instance, project, reason: unavailable.code }); continue; }
|
||||
let answer = null;
|
||||
try { answer = await host.op({ op: 'request.admission', business: b.id, project }); } catch (e) { answer = { ready: false, code: e.code ?? 'admission-unavailable' }; }
|
||||
if (answer?.ready !== true) { record({ event: "checkout-skipped", instance, project, reason: answer?.code ?? 'project-pending' }); continue; }
|
||||
codeProjects[project] = entry;
|
||||
}
|
||||
if (own !== null && !Object.hasOwn(codeProjects, own)) throw new Refusal("project-refused", `${instance} works only in project ${own}, which isn't ready`);
|
||||
await threadAdmission(thread, session);
|
||||
let checkouts;
|
||||
try {
|
||||
@@ -646,9 +671,30 @@ export function createLauncher({
|
||||
catch (error) { throw new Refusal(error.code ?? 'proxy-down', error.code ?? 'proxy-down'); }
|
||||
if (!admittedProxy || typeof admittedProxy.key !== 'string' || !admittedProxy.key || admittedProxy.baseUrl !== `http://127.0.0.1:${system.proxy.port}`) throw new Refusal('proxy-down', 'proxy-down');
|
||||
}
|
||||
let harness, route, routeReason, sessionProxy, resolver;
|
||||
({resolved,model,account,harness,route,routeReason,sessionProxy,resolver}=await check(instance,{pm:instance===b.launch.by,thread,session,human,proxyContext:admittedProxy,proxyReady:true}));
|
||||
let harness, route, routeReason, sessionProxy, resolver, revision;
|
||||
({resolved,model,account,harness,route,routeReason,sessionProxy,resolver,revision}=await check(instance,{pm:instance===b.launch.by,thread,session,human,proxyContext:admittedProxy,proxyReady:true}));
|
||||
admission(admissionGeneration);
|
||||
// #1873 A3: check() read the organization again. A project that excluded or refused this
|
||||
// instance while its checkouts were prepared refuses the launch; the next one leaves it out.
|
||||
for (const project of Object.keys(codeProjects)) {
|
||||
const unavailable = projectRefusal(b, instance, project);
|
||||
if (unavailable) throw new Refusal(unavailable.code, unavailable.message);
|
||||
}
|
||||
// #1873 A3: every declared code project this launch leaves out, in the document it started from
|
||||
// or the one check() read, is hidden if an earlier launch left its checkout in the workspace,
|
||||
// whatever kept it out (not admitted, excluded, refused, another project's agent). Only a real
|
||||
// directory can be covered: a tmpfs mounted at a symlink would land on its target, so anything
|
||||
// else at that path refuses the launch, and nothing on the host is changed or removed.
|
||||
for (const [project, entry] of Object.entries(b.projects)) if (entry.kind !== 'library') declared.add(project);
|
||||
const hidden = [];
|
||||
for (const project of declared) {
|
||||
if (Object.hasOwn(codeProjects, project)) continue;
|
||||
const path = join(workspace, project);
|
||||
let stat;
|
||||
try { stat = lstatSync(path); } catch (e) { if (e.code === "ENOENT") continue; throw new Refusal("checkout-unsafe", `${instance}'s workspace path for project ${project} can't be read`); }
|
||||
if (!stat.isDirectory()) throw new Refusal("checkout-unsafe", `${instance}'s workspace path for project ${project} isn't a directory, so it can't be hidden; a person removes it before ${instance} launches again`);
|
||||
hidden.push(path);
|
||||
}
|
||||
const { run, runDir } = newRun(dataRoot, b.id);
|
||||
let modelKeyId;
|
||||
if (admittedProxy) {
|
||||
@@ -744,6 +790,7 @@ export function createLauncher({
|
||||
gitSocket: git,
|
||||
threadStore: store,
|
||||
readOnly,
|
||||
hidden,
|
||||
};
|
||||
admission(admissionGeneration);
|
||||
await threadAdmission(thread, session);
|
||||
@@ -754,7 +801,9 @@ export function createLauncher({
|
||||
}
|
||||
// One synchronous broker snapshot covers the thread and the entire manifest after every
|
||||
// asynchronous preparation read. Admission is the launch boundary; bindLaunch remains a fence.
|
||||
const finalAdmission = await host.op({ op: "request.admission", business: b.id, projects: Object.keys(codeProjects), thread, session });
|
||||
// #1873 A3: `revision` is the organization file check() resolved the bundle from; a file that
|
||||
// changed since (a narrow, a define's vars, an exclude) refuses, and the next launch resolves anew.
|
||||
const finalAdmission = await host.op({ op: "request.admission", business: b.id, projects: Object.keys(codeProjects), instance, revision, thread, session });
|
||||
if (finalAdmission?.ready !== true) throw new Refusal(finalAdmission?.code ?? "project-pending");
|
||||
const managed = registerRuntime(dataRoot, { kind: 'session', business: b.id, run });
|
||||
let child;
|
||||
@@ -889,6 +938,8 @@ export function createLauncher({
|
||||
for (const i of pending) {
|
||||
const session = registry.running(b.id, i.target, null);
|
||||
if (!session || !i.project || stopping.has(session.run)) continue;
|
||||
// #1873 A3: a manifest never gains a project its instance doesn't work in.
|
||||
if (projectRefusal(b, i.target, i.project)) continue;
|
||||
let projects = [];
|
||||
try { projects = JSON.parse(readFileSync(join(session.runDir, 'bundle/manifest.json'), 'utf8')).projects ?? []; } catch {}
|
||||
if (!projects.includes(i.project)) {
|
||||
@@ -1348,7 +1399,7 @@ export function createLauncher({
|
||||
if (typeof instance === 'string') await host.op({ op: 'request.launchResult', record: { business: b.id, instance, code: out.ok ? null : out.error, run: out.ok ? out.result.run : null } });
|
||||
return out;
|
||||
};
|
||||
try { b = loadBusiness(host.business, { dir: configDir(env), rolesDir: rolesDir(env) }); }
|
||||
try { b = loadOrg(); }
|
||||
catch { return reply({ ok: false, error: 'organization-refused' }); }
|
||||
// Product enums and clones are frozen in a session's manifest. A legitimate chief
|
||||
// launch may request a drain, never interrupt the turn or start a privileged worker.
|
||||
|
||||
@@ -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.message}`, 2); } }
|
||||
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); } }
|
||||
export async function orgCommand(args, io, deps = {}) {
|
||||
try {
|
||||
const p = parse(args); const words = p.pos; let result; let accepted = [];
|
||||
|
||||
@@ -290,7 +290,7 @@ test("the real human-cli, run from agent ancestry, refuses and writes nothing",
|
||||
const config = join(root, "config");
|
||||
const bus = await startBroker({
|
||||
dataRoot: f.dataRoot,
|
||||
businesses: { acme: { id: "acme", human: "jason", arbiters: { technical: "coder", delivery: "coder" }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } },
|
||||
businesses: { acme: { id: "acme", human: "jason", arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } },
|
||||
organization: { dir: config, rolesDir: f.roles, system: systemFor(root) },
|
||||
});
|
||||
t.after(() => bus.close());
|
||||
@@ -353,7 +353,7 @@ test("T2 a bad user add refuses before any write", async (t) => {
|
||||
}
|
||||
const checked = [
|
||||
[USER_ADD("bob", { subject: "github:bob-sub" }), /provider must be a configured provider/],
|
||||
[USER_ADD("alice", { subject: "authentik:alice-2" }), /duplicate user id: alice/],
|
||||
[USER_ADD("alice", { subject: "authentik:alice-2" }), /duplicate user id/],
|
||||
[USER_ADD("tester"), /a bus principal/],
|
||||
[USER_ADD("jason"), /a business's human/],
|
||||
[USER_ADD("bob", { subject: "authentik:jason-sub" }), /owner sign-in subject/],
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, chmodSync, writeFileSync, readFileSync, mkdirSync, existsSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { accountsCommand } from '../src/accounts.mjs';
|
||||
import { main } from '../src/cli.mjs';
|
||||
import { withRegistry, reserveOrg } from '../../organization/src/org-registry.mjs';
|
||||
const providers = [{ id: 'pocket', issuer: 'https://id.example', subjects: [{ sub: 'owner' }, { sub: 'other-owner' }] }, { id: 'second', issuer: 'https://second.example', subjects: [{ sub: 'owner' }] }];
|
||||
function setup(t) {
|
||||
const configDir = mkdtempSync(join(tmpdir(), 'a-')); chmodSync(configDir, 0o700); t.after(() => rmSync(configDir, { recursive: true, force: true }));
|
||||
const deps = { configDir, instance: 'test', system: { console: { sso: { providers } } } }; let output = '';
|
||||
const io = { env: {}, stdout: { write: s => output += s }, stderr: { write: () => {} } };
|
||||
return { configDir, deps, io, run: args => accountsCommand(args, io, deps), read: () => JSON.parse(readFileSync(join(configDir, 'accounts.json'))), output: () => output };
|
||||
}
|
||||
test('init makes exactly one operator from all configured subjects, then refuses rerun', async t => {
|
||||
const s = setup(t); await s.run(['init']); const d = s.read(); assert.equal(d.users.length, 1); assert.equal(d.users[0].instanceRole, 'operator'); assert.equal(d.users[0].subjects.length, 3); assert.match(d.users[0].id, /^u-[0-9a-f]{12}$/); assert.equal(d.revision, 1);
|
||||
await assert.rejects(s.run(['init']), /already exists/); await s.run(['list', '--json']); assert.match(s.output(), /authGen/);
|
||||
const r = JSON.parse(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8')); assert.equal(r.uid, process.getuid()); assert.equal(r.verb, 'init'); assert.ok(!JSON.stringify(r).includes('other-owner'));
|
||||
});
|
||||
test('CLI add, disable, enable, signout and remove; last operator guard and stale revisions', async t => {
|
||||
const s = setup(t); await s.run(['init']); const op = s.read().users[0].id;
|
||||
await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat:sub', '--revision', '1']); const id = s.read().users[1].id;
|
||||
for (const [verb, revision, generation] of [['disable', 2, 2], ['enable', 3, 3], ['signout', 4, 4]]) { await s.run(['user', verb, id, '--revision', String(revision)]); assert.equal(s.read().users[1].authGen, generation); }
|
||||
await assert.rejects(s.run(['user', 'disable', op, '--revision', '5']), /active operator/);
|
||||
await assert.rejects(s.run(['user', 'signout', id, '--revision', '1']), /revision-conflict/);
|
||||
await s.run(['user', 'remove', id, '--revision', '5']); assert.equal(s.read().users.length, 1); assert.equal(s.read().revision, 6);
|
||||
assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8').trim().split('\n').length, 6);
|
||||
});
|
||||
test('host uid refusal happens before any lock, even on list or remove', async t => {
|
||||
const s = setup(t); for (const args of [['list'], ['init'], ['user', 'remove', 'u-000000000001', '--revision', '1']]) {
|
||||
await assert.rejects(accountsCommand(args, s.io, { ...s.deps, uid: process.getuid() + 1 }), /uid/);
|
||||
assert.equal(existsSync(join(s.configDir, 'accounts.lock')), false); assert.equal(existsSync(join(s.configDir, 'org-runtime.lock')), false);
|
||||
}
|
||||
});
|
||||
test('remove holds registry before accounts, refuses ownership of active or archived orgs, including a preceding create', async t => {
|
||||
const s = setup(t); await s.run(['init']); await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat', '--revision', '1']); const id = s.read().users[1].id;
|
||||
const root = join(s.configDir, 'acme'); mkdirSync(root, { mode: 0o700 });
|
||||
let release, entered; const ready = new Promise(r => entered = r), hold = new Promise(r => release = r);
|
||||
const create = withRegistry(s.deps, async registry => { entered(); await hold; registry.orgs.acme = { id: 'acme', root, state: 'archived', revision: 1, created: '2026-11-10T00:00:00Z' }; writeFileSync(join(root, 'owner.json'), JSON.stringify({ provider: 'pocket', iss: 'https://id.example', sub: 'pat' }), { mode: 0o600 }); });
|
||||
await ready; const removing = s.run(['user', 'remove', id, '--revision', '2']); release(); await create; await assert.rejects(removing, /user-owns-org/); assert.equal(s.read().revision, 2);
|
||||
});
|
||||
test('remove drops memberships atomically, and malformed ownership metadata refuses', async t => {
|
||||
const s = setup(t); await s.run(['init']); await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat', '--revision', '1']); const d = s.read(), id = d.users[1].id;
|
||||
d.memberships.push({ org: 'acme', incarnation: 'a'.repeat(32), user: id, role: 'member', rights: ['org.view'], scope: { projects: ['*'], instances: ['*'] }, rev: 1, by: d.users[0].id, added: '2026-11-10T00:00:00Z', via: 'invite:abc' }); writeFileSync(join(s.configDir, 'accounts.json'), JSON.stringify(d));
|
||||
await s.run(['user', 'remove', id, '--revision', '2']); assert.deepEqual(s.read().memberships, []);
|
||||
const root = join(s.configDir, 'broken'); mkdirSync(root, { mode: 0o700 });
|
||||
await withRegistry(s.deps, async registry => { registry.orgs.broken = { id: 'broken', root, state: 'active', revision: 1, created: 'now' }; writeFileSync(join(root, 'owner.json'), '{}', { mode: 0o600 }); });
|
||||
await assert.rejects(s.run(['user', 'remove', s.read().users[0].id, '--revision', '3']), /owner/);
|
||||
});
|
||||
test('mosaic main wires accounts without a bus; tester, agent and unexpected args refuse', async t => {
|
||||
const s = setup(t); await main(['accounts', 'init'], s.io, { system: s.deps.system, accountsConfigDir: s.configDir }); assert.equal(s.read().users.length, 1);
|
||||
for (const args of [['list', '--tester'], ['list', '--subject', 'pocket:x'], ['user', 'add', 'chosen-id', '--name', 'Pat', '--subject', 'pocket:x', '--revision', '1'], ['list', '--json', '--json']]) await assert.rejects(s.run(args));
|
||||
await assert.rejects(accountsCommand(['list'], { ...s.io, env: { MOSAIC_RUN_ID: 'run' } }, s.deps), /human shell/);
|
||||
});
|
||||
|
||||
|
||||
test('review P2-2: real reservation wins before owner.json exists, and removal refuses', async t => {
|
||||
const s = setup(t); await s.run(['init']); await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat', '--revision', '1']); const id = s.read().users[1].id;
|
||||
const dataRoot = join(s.configDir, 'data'); mkdirSync(dataRoot, { mode: 0o700 }); const ctx = { configDir: s.configDir, instance: 'test', dataRoot };
|
||||
const org = await reserveOrg(ctx, 'acme'); assert.equal(existsSync(join(org.root, 'owner.json')), false);
|
||||
await assert.rejects(s.run(['user', 'remove', id, '--revision', '2']), /org-create-in-progress/); assert.equal(s.read().users.length, 2); assert.equal(s.read().revision, 2);
|
||||
});
|
||||
|
||||
|
||||
test('host boundary and receipt use effective uid, not real uid (current design clarification)', async t => {
|
||||
const s = setup(t), realUid = process.getuid, effectiveUid = process.geteuid();
|
||||
process.getuid = () => effectiveUid + 1;
|
||||
try { await s.run(['init']); await s.run(['list']); const receipt = JSON.parse(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8')); assert.equal(receipt.uid, effectiveUid); }
|
||||
finally { process.getuid = realUid; }
|
||||
});
|
||||
@@ -0,0 +1,595 @@
|
||||
import { requestHostOps } from './request-host-ops.mjs';
|
||||
// #1873 Phase A slice A3: root and project agent copies through the real bus and launcher. The
|
||||
// cli's boot step reads the root file once and hands the snapshot to the broker and the launcher;
|
||||
// a bound copy launches as an ordinary instance of the organization, an unbound or refused one
|
||||
// refuses at the verb, and a project agent gets only its own project. Real runners in the bwrap
|
||||
// sandbox on the fake adapter, with the in-process host lifecycle.test.mjs uses.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { businessDoc, launchDoc, tokenFile, writeJson } from "../../organization/tests/helpers.mjs";
|
||||
import { BusError } from "../../bus/src/broker.mjs";
|
||||
import { Client } from "../../bus/src/client.mjs";
|
||||
import { startBroker } from "../../bus/src/runtime.mjs";
|
||||
import { gitEnv } from "../../seat/src/checkout.mjs";
|
||||
import { launchLogFile } from "../../seat/src/session.mjs";
|
||||
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
import { startTimeOf } from "../src/host.mjs";
|
||||
import { createLauncher } from "../src/launcher.mjs";
|
||||
import { fakeHarnesses, fixture, tmp, withoutHostCredentials } from "./helpers.mjs";
|
||||
|
||||
const FAKE = fileURLToPath(new URL("../../harness/tests/fixtures/fake-adapter.mjs", import.meta.url));
|
||||
const skip = sandboxProblem() ?? false;
|
||||
const SECRET = "sk-test-NOT-A-SECRET-1234";
|
||||
const VARS = { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" };
|
||||
|
||||
async function until(fn, ms = 15000) {
|
||||
const end = Date.now() + ms;
|
||||
while (Date.now() < end) {
|
||||
const v = await fn();
|
||||
if (v) return v;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
throw new Error("timed out waiting");
|
||||
}
|
||||
|
||||
function gitRepo(dir) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const git = (...args) => {
|
||||
const r = spawnSync("git", ["-c", "maintenance.auto=false", "-c", "gc.auto=0", "-c", "user.name=test", "-c", "[email protected]", "-C", dir, ...args], { env: gitEnv(), encoding: "utf8" });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
};
|
||||
git("init", "-q", "-b", "main");
|
||||
writeFileSync(join(dir, "file.txt"), "one\n");
|
||||
git("add", "file.txt");
|
||||
git("commit", "-q", "-m", "one");
|
||||
}
|
||||
|
||||
// acme: two code projects, stack and web. stack defines the project agent checker. The root file
|
||||
// holds gate (merge-gate, no credential: bound), researcher (research, needs gitea: unbound in every
|
||||
// organization) and sec (security-reviewer, excluded from acme). The coder's gitea token file is
|
||||
// named for, and holds, the test secret, so it is bound elsewhere in the organization.
|
||||
function prepare(t, edit = (doc) => doc) {
|
||||
const root = tmp(t, "mosaic-copy-");
|
||||
const f = fixture(root, "acme", (doc) => {
|
||||
doc.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
|
||||
doc.roles.coder.vars.model = "claude-sonnet-5-5";
|
||||
const secret = tokenFile(join(root, "secrets"), SECRET);
|
||||
writeFileSync(secret, SECRET);
|
||||
doc.roles.coder.credentials.gitea = { ...doc.roles.coder.credentials.gitea, file: secret };
|
||||
doc.launch = launchDoc("chief", ["coder"], { opus: 1, sonnet: 4 });
|
||||
doc.projects.stack.agents = { checker: { definition: "merge-gate", vars: VARS } };
|
||||
doc.projects.web = { root: join(root, "web") };
|
||||
return edit(doc);
|
||||
});
|
||||
withoutHostCredentials(f.env, { piKey: true });
|
||||
gitRepo(f.doc.projects.stack.root);
|
||||
gitRepo(f.doc.projects.web.root);
|
||||
writeJson(join(root, "config", "agents.json"), { agentsVersion: 1, agents: {
|
||||
gate: { definition: "merge-gate", vars: VARS },
|
||||
researcher: { definition: "research", vars: VARS },
|
||||
sec: { definition: "security-reviewer", vars: VARS, excludeOrgs: ["acme"] },
|
||||
} }, 0o600);
|
||||
const adapter = join(root, "adapter.sh");
|
||||
writeFileSync(adapter, `exec '${process.execPath}' '${FAKE}'\n`, { mode: 0o644 });
|
||||
return { root, f, adapter };
|
||||
}
|
||||
|
||||
async function start(t, { f, adapter }, { businessId = "acme", orgRoot } = {}) {
|
||||
const system = loadSystem({ env: f.env });
|
||||
const { trackers, ...boot } = bootConfig({ system, businessId, env: f.env, ...(orgRoot ? { orgRoot } : {}) });
|
||||
const runtime = await startBroker(boot);
|
||||
const dataRoot = boot.dataRoot;
|
||||
mkdirSync(join(dataRoot, "bus-host"), { recursive: true, mode: 0o700 });
|
||||
const ops = {
|
||||
...requestHostOps(runtime),
|
||||
identity: (m) => runtime.identity(m.cap),
|
||||
authorizeLaunch: (m) => runtime.authorizeLaunch(m.cap, m.instance),
|
||||
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
|
||||
endLaunch: (m) => runtime.endLaunch(m.record),
|
||||
credentialStatus: (m) => runtime.credentialStatus(m.business, m.role),
|
||||
threadsState: (m) => runtime.threadsState(m.business),
|
||||
threadIdle: (m) => runtime.threadIdle(m.record),
|
||||
mayLaunch: (m) => runtime.mayLaunch(m),
|
||||
landHeld: (m) => runtime.landHeld(m.record),
|
||||
landHolders: (m) => runtime.landHolders(m.business),
|
||||
landRelease: (m) => runtime.landRelease(m.record),
|
||||
lifecycleRequests: (m) => runtime.lifecycleRequests(m.business),
|
||||
lifecycleTake: (m) => runtime.lifecycleTake(m.record),
|
||||
lifecycleDelete: (m) => runtime.lifecycleDelete(m.record),
|
||||
lifecycleDone: (m) => runtime.lifecycleDone(m.record),
|
||||
};
|
||||
const refusing = (what, fn) => async (m) => {
|
||||
try {
|
||||
return fn(m);
|
||||
} catch (e) {
|
||||
const code = e instanceof BusError ? e.code : `${what}-refused`;
|
||||
throw Object.assign(new Error(`${what} refused: ${code}`), { code });
|
||||
}
|
||||
};
|
||||
const hooks = [];
|
||||
let closing = null;
|
||||
const host = {
|
||||
business: businessId,
|
||||
path: runtime.path,
|
||||
dataRoot,
|
||||
op: refusing("launch-op", (m) => {
|
||||
if (!Object.hasOwn(ops, m.op)) throw new BusError("invalid-host-request");
|
||||
return ops[m.op](m);
|
||||
}),
|
||||
bindLaunch: refusing("launch bind", (r) => runtime.bindLaunch(r)),
|
||||
beforeClose: (fn) => void hooks.push(fn),
|
||||
};
|
||||
const logs = [];
|
||||
const launcher = createLauncher({
|
||||
host,
|
||||
system: orgRoot ? { ...system, dataRoot } : system,
|
||||
env: f.env,
|
||||
makeLoader: await fakeHarnesses(t, { pi: adapter, "claude-code": adapter }),
|
||||
sessionDefaults: { pollInterval: 100 },
|
||||
harnessVersion: "0.85.1",
|
||||
readOnly: [FAKE],
|
||||
threadTickMs: 3600000,
|
||||
stopTimeoutMs: 1000,
|
||||
log: (l) => logs.push(l),
|
||||
copies: boot.copies ?? null,
|
||||
});
|
||||
const close = () =>
|
||||
(closing ??= (async () => {
|
||||
for (const fn of hooks) await fn(0);
|
||||
await runtime.close();
|
||||
})());
|
||||
t.after(close);
|
||||
await launcher.listen();
|
||||
const human = runtime.broker.bindHuman({ business: businessId, human: f.doc.human, via: "cli", outsideAgent: true });
|
||||
const call = (verb, args = {}) => runtime.broker.request(human, { verb, args });
|
||||
const carry = async (request) => {
|
||||
await launcher.tick();
|
||||
return call("instance.request", { request });
|
||||
};
|
||||
const running = (instance) => launcher.registry.sessions.filter((s) => s.instance === instance);
|
||||
return { boot, dataRoot, runtime, launcher, host, call, carry, running, logs, close };
|
||||
}
|
||||
|
||||
// What the bus's trail holds: every event body, as text.
|
||||
function trail(dataRoot) {
|
||||
const db = new DatabaseSync(join(dataRoot, "bus", "bus.sqlite"), { readOnly: true });
|
||||
try {
|
||||
return db.prepare("SELECT kind, body FROM events").all().map((e) => `${e.kind} ${e.body}`).join("\n");
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
const refusedAt = (s, instance) => {
|
||||
try {
|
||||
s.call("instance.launch", { instance });
|
||||
} catch (e) {
|
||||
assert.ok(e instanceof BusError, String(e));
|
||||
return e;
|
||||
}
|
||||
assert.fail(`${instance} launched`);
|
||||
};
|
||||
|
||||
test("A3: a bound root copy launches as an instance of the organization; the excluded one and the root assistant aren't there", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
assert.deepEqual(Object.keys(s.boot.copies.copies).sort(), ["checker.stack", "gate", "researcher"]);
|
||||
const done = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(done.done.refusal, null, JSON.stringify(done));
|
||||
const [run] = s.running("gate");
|
||||
assert.equal(run.run, done.done.run);
|
||||
// The grid read names it running from the bus's own claims.
|
||||
await until(() => readFileSync(join(s.dataRoot, "launches", "acme", run.run, "runner.log"), "utf8").includes("claimed by run"));
|
||||
const grid = s.call("instances");
|
||||
assert.equal(grid.copies.gate.state, "instance");
|
||||
assert.deepEqual(grid.copies.gate.running, [run.run]);
|
||||
// sec is excluded from acme: no copy, no instance, and a launch names it unknown.
|
||||
assert.equal(Object.hasOwn(grid.copies, "sec"), false);
|
||||
assert.equal(refusedAt(s, "sec").code, "unknown-role");
|
||||
});
|
||||
|
||||
test("A3: an unbound copy refuses at the verb by name and binding; no value reaches the refusal, the trail or the grid", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const e = refusedAt(s, "researcher");
|
||||
assert.equal(e.code, "credential-unbound");
|
||||
assert.match(e.reason, /^researcher has no binding in acme for gitea$/);
|
||||
const grid = JSON.stringify(s.call("instances"));
|
||||
assert.match(grid, /"researcher":\{[^}]*"state":"unbound"/);
|
||||
for (const text of [e.message, e.reason, grid, trail(s.dataRoot), s.logs.join("\n")]) {
|
||||
assert.equal(text.includes(SECRET), false);
|
||||
assert.equal(text.includes(p.root), false, "no host path");
|
||||
}
|
||||
// Nothing was asked of the launcher.
|
||||
await s.launcher.tick();
|
||||
assert.equal(s.running("researcher").length, 0);
|
||||
});
|
||||
|
||||
test("A3: a project agent launches with only its own project's mapping; a project whose union refuses refuses every agent in it", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const done = await s.carry(s.call("instance.launch", { instance: "checker.stack" }).request);
|
||||
assert.equal(done.done.refusal, null, JSON.stringify(done));
|
||||
const [run] = s.running("checker.stack");
|
||||
const manifest = JSON.parse(readFileSync(join(run.runDir, "bundle", "manifest.json"), "utf8"));
|
||||
assert.deepEqual(manifest.projects, ["stack"]);
|
||||
// An org instance of the same role takes both code projects.
|
||||
const coder = await s.carry(s.call("instance.launch", { instance: "coder" }).request);
|
||||
assert.equal(coder.done.refusal, null, JSON.stringify(coder));
|
||||
const [c] = s.running("coder");
|
||||
assert.deepEqual(JSON.parse(readFileSync(join(c.runDir, "bundle", "manifest.json"), "utf8")).projects.sort(), ["stack", "web"]);
|
||||
|
||||
// web excludes an agent nothing defines: its union refuses, so its agents refuse, naming web.
|
||||
const q = prepare(t, (doc) => {
|
||||
doc.projects.web.agents = { checker: { definition: "merge-gate", vars: VARS }, lint: { definition: "security-reviewer", vars: VARS }, ghost: { exclude: true } };
|
||||
return doc;
|
||||
});
|
||||
const r = await start(t, q);
|
||||
for (const name of ["checker.web", "lint.web"]) {
|
||||
const e = refusedAt(r, name);
|
||||
assert.equal(e.code, "project-refused");
|
||||
assert.match(e.reason, new RegExp(`^${name.replace(".", "\\.")} can't launch in acme: project web refuses its agents: .*ghost`));
|
||||
}
|
||||
// The other project's agent is untouched.
|
||||
assert.equal((await r.carry(r.call("instance.launch", { instance: "checker.stack" }).request)).done.refusal, null);
|
||||
// An inherited root copy and an organization instance still launch, without web's mount.
|
||||
for (const instance of ["gate", "coder"]) {
|
||||
const out = await r.carry(r.call("instance.launch", { instance }).request);
|
||||
assert.equal(out.done.refusal, null, JSON.stringify(out));
|
||||
const [inherited] = r.running(instance);
|
||||
assert.deepEqual(projectsOf(inherited), ["stack"], instance);
|
||||
}
|
||||
});
|
||||
|
||||
const projectsOf = (run) => JSON.parse(readFileSync(join(run.runDir, "bundle", "manifest.json"), "utf8")).projects.sort();
|
||||
const bundleOf = (run, file) => JSON.parse(readFileSync(join(run.runDir, "bundle", file), "utf8"));
|
||||
|
||||
test("A3: a project's exclude takes its mount from an inherited agent, and its narrow reaches the agent's real bundle", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t, (doc) => {
|
||||
doc.projects.stack.agents.gate = { vars: { "limits.tools": ["read"] } };
|
||||
doc.projects.web.agents = { gate: { exclude: true } };
|
||||
return doc;
|
||||
});
|
||||
const s = await start(t, p);
|
||||
const done = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(done.done.refusal, null, JSON.stringify(done));
|
||||
const [run] = s.running("gate");
|
||||
assert.deepEqual(projectsOf(run), ["stack"]);
|
||||
const tools = bundleOf(run, "policy.json").tools;
|
||||
assert.ok(tools.includes("read"), JSON.stringify(tools));
|
||||
assert.equal(tools.includes("grep"), false, JSON.stringify(tools));
|
||||
// The launch record names the skipped project and the code, never a reason text.
|
||||
const skipped = readFileSync(launchLogFile(s.dataRoot, "acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l)).filter((e) => e.event === "checkout-skipped");
|
||||
assert.deepEqual(skipped.map(({ instance, project, reason }) => ({ instance, project, reason })), [{ instance: "gate", project: "web", reason: "project-refused" }]);
|
||||
});
|
||||
|
||||
// Writes the organization file the way a human edit does, outside any writer verb.
|
||||
const editOrg = (p, change) => {
|
||||
const file = join(p.root, "config", "businesses", "acme.json");
|
||||
const doc = JSON.parse(readFileSync(file, "utf8"));
|
||||
change(doc);
|
||||
writeFileSync(file, JSON.stringify(doc, null, 2) + "\n", { mode: 0o600 });
|
||||
};
|
||||
// A writer verb reloads the broker from the file. Keeping coder twice changes nothing and refuses,
|
||||
// so a repeat reload drops the keep instead.
|
||||
const reload = (s) => {
|
||||
try { return s.call("veto.set", { revision: s.call("instances").revision, op: "keep", name: "coder" }); }
|
||||
catch (e) { if (e.code !== "organization-no-change") throw e; }
|
||||
return s.call("veto.set", { revision: s.call("instances").revision, op: "unkeep", name: "coder" });
|
||||
};
|
||||
// What the run's first turn said, from inside the real sandbox, about the web checkout.
|
||||
async function webSeen(s, run) {
|
||||
await until(() => existsSync(join(run.runDir, "runner.log")) && readFileSync(join(run.runDir, "runner.log"), "utf8").includes("claimed by run"));
|
||||
s.call("message.send", { to: run.instance, class: "REQUEST", body: "fixture" });
|
||||
const said = join(run.runDir, "turns", "0001.stderr");
|
||||
await until(() => existsSync(said) && /WEB-(READABLE|ABSENT)/.test(readFileSync(said, "utf8")));
|
||||
return readFileSync(said, "utf8").match(/WEB-(READABLE|ABSENT)/)[1];
|
||||
}
|
||||
|
||||
test("A3: a checkout an earlier launch made is out of sight in the sandbox once its project excludes the agent, and stays on the host", { skip, timeout: 90000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
// The adapter runs inside the sandbox and says only whether the web checkout's file is readable.
|
||||
writeFileSync(p.adapter, `if test -r "$MOSAIC_WORKSPACE/web/file.txt"; then echo WEB-READABLE >&2; else echo WEB-ABSENT >&2; fi\nexec '${process.execPath}' '${FAKE}'\n`);
|
||||
const s = await start(t, p);
|
||||
let out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(out.done.refusal, null, JSON.stringify(out));
|
||||
let [run] = s.running("gate");
|
||||
assert.deepEqual(projectsOf(run), ["stack", "web"]);
|
||||
assert.equal(await webSeen(s, run), "READABLE");
|
||||
await s.carry(s.call("instance.end", { instance: "gate" }).request);
|
||||
await until(() => s.running("gate").length === 0);
|
||||
for (const exclude of [{ gate: { exclude: true } }, { ghost: { exclude: true } }]) {
|
||||
// The project excludes gate, then refuses its whole union.
|
||||
editOrg(p, (d) => { d.projects.web.agents = exclude; });
|
||||
reload(s);
|
||||
out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(out.done.refusal, null, JSON.stringify(out));
|
||||
[run] = s.running("gate");
|
||||
assert.deepEqual(projectsOf(run), ["stack"]);
|
||||
assert.equal(await webSeen(s, run), "ABSENT");
|
||||
await s.carry(s.call("instance.end", { instance: "gate" }).request);
|
||||
await until(() => s.running("gate").length === 0);
|
||||
}
|
||||
// The earlier work is still on the host, untouched.
|
||||
assert.equal(readFileSync(join(s.dataRoot, "workspaces", "acme", "gate", "web", "file.txt"), "utf8"), "one\n");
|
||||
});
|
||||
|
||||
test("A3: a project that excludes the agent while its launch is prepared refuses the launch; the bus's last admission asks too", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const op = s.host.op;
|
||||
let changed = false;
|
||||
s.host.op = async (m) => {
|
||||
const out = await op(m);
|
||||
if (!changed && m.op === "request.admission" && m.project === "web") {
|
||||
changed = true;
|
||||
editOrg(p, (d) => { d.projects.web.agents = { gate: { exclude: true } }; });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(changed, true);
|
||||
assert.equal(out.done.refusal, "project-refused", JSON.stringify(out));
|
||||
assert.equal(s.running("gate").length, 0);
|
||||
// Once the bus has the change, its launch-boundary admission refuses the instance there too.
|
||||
reload(s);
|
||||
const revision = s.call("instances").revision;
|
||||
assert.deepEqual(s.runtime.admission({ business: "acme", projects: ["stack", "web"], instance: "gate", revision }).code, "project-refused");
|
||||
assert.equal(s.runtime.admission({ business: "acme", projects: ["stack"], instance: "gate", revision }).ready, true);
|
||||
assert.equal(s.runtime.admission({ business: "acme", projects: ["stack", "web"] }).ready, true);
|
||||
// The launch boundary takes an instance only with the revision its bundle came from.
|
||||
assert.equal(s.runtime.admission({ business: "acme", projects: ["stack"], instance: "gate" }).code, "project-unknown");
|
||||
assert.equal(s.runtime.admission({ business: "acme", projects: ["stack"], instance: "gate", revision: "0".repeat(64) }).code, "organization-changed");
|
||||
});
|
||||
|
||||
// Launches gate with both projects, reads web from inside the sandbox, and ends the run, so the
|
||||
// workspace keeps a web checkout an earlier launch made.
|
||||
async function launchBoth(s) {
|
||||
const out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(out.done.refusal, null, JSON.stringify(out));
|
||||
const [run] = s.running("gate");
|
||||
assert.deepEqual(projectsOf(run), ["stack", "web"]);
|
||||
assert.equal(await webSeen(s, run), "READABLE");
|
||||
await s.carry(s.call("instance.end", { instance: "gate" }).request);
|
||||
await until(() => s.running("gate").length === 0);
|
||||
}
|
||||
const readsWeb = (p) => writeFileSync(p.adapter, `if test -r "$MOSAIC_WORKSPACE/web/file.txt"; then echo WEB-READABLE >&2; else echo WEB-ABSENT >&2; fi\nexec '${process.execPath}' '${FAKE}'\n`);
|
||||
|
||||
test("A3: a checkout of a project the bus doesn't admit now is out of sight too, not only an excluded one", { skip, timeout: 90000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
readsWeb(p);
|
||||
const s = await start(t, p);
|
||||
await launchBoth(s);
|
||||
// web is declared and gate is in its union, but the bus doesn't admit the project for this launch.
|
||||
const op = s.host.op;
|
||||
s.host.op = async (m) => (m.op === "request.admission" && m.project === "web" ? { ready: false, revision: null, code: "project-pending" } : op(m));
|
||||
const out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(out.done.refusal, null, JSON.stringify(out));
|
||||
const [run] = s.running("gate");
|
||||
assert.deepEqual(projectsOf(run), ["stack"]);
|
||||
assert.equal(await webSeen(s, run), "ABSENT");
|
||||
assert.equal(readFileSync(join(s.dataRoot, "workspaces", "acme", "gate", "web", "file.txt"), "utf8"), "one\n");
|
||||
});
|
||||
|
||||
test("A3: a symlink or a file where a hidden checkout would be refuses the launch, and nothing on the host is touched", { skip, timeout: 90000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
readsWeb(p);
|
||||
const s = await start(t, p);
|
||||
await launchBoth(s);
|
||||
const ws = join(s.dataRoot, "workspaces", "acme", "gate");
|
||||
// The run's own workspace moves the checkout aside and leaves a link at web, then web excludes gate.
|
||||
renameSync(join(ws, "web"), join(ws, "kept"));
|
||||
symlinkSync(join(ws, "kept"), join(ws, "web"));
|
||||
editOrg(p, (d) => { d.projects.web.agents = { gate: { exclude: true } }; });
|
||||
reload(s);
|
||||
let out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(out.done.refusal, "checkout-unsafe", JSON.stringify(out));
|
||||
assert.equal(s.running("gate").length, 0);
|
||||
assert.equal(lstatSync(join(ws, "web")).isSymbolicLink(), true);
|
||||
assert.equal(readFileSync(join(ws, "kept", "file.txt"), "utf8"), "one\n");
|
||||
// A plain file there refuses the same way.
|
||||
renameSync(join(ws, "web"), join(ws, "link"));
|
||||
writeFileSync(join(ws, "web"), "not a checkout\n");
|
||||
out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(out.done.refusal, "checkout-unsafe", JSON.stringify(out));
|
||||
assert.equal(readFileSync(join(ws, "web"), "utf8"), "not a checkout\n");
|
||||
});
|
||||
|
||||
test("A3: a narrow that lands after the launcher's recheck and before the bus's last admission refuses the launch; the next one takes it", { skip, timeout: 90000 }, async (t) => {
|
||||
for (const [instance, narrow, after] of [
|
||||
// An inherited agent's narrow in its project.
|
||||
["gate", (d) => { d.projects.stack.agents.gate = { vars: { "limits.tools": ["read"] } }; }, "launches"],
|
||||
// A project agent's define: its vars change after boot.
|
||||
["checker.stack", (d) => { d.projects.stack.agents.checker.vars["limits.tools"] = ["read"]; }, "project-refused"],
|
||||
]) {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const op = s.host.op;
|
||||
let landed = false;
|
||||
// The narrow lands after every launcher read, just before the bus's last admission answers.
|
||||
s.host.op = async (m) => {
|
||||
if (!landed && m.op === "request.admission" && m.projects !== undefined) { landed = true; editOrg(p, narrow); reload(s); }
|
||||
return op(m);
|
||||
};
|
||||
let out = await s.carry(s.call("instance.launch", { instance }).request);
|
||||
assert.equal(landed, true, instance);
|
||||
assert.equal(out.done.refusal, "organization-changed", `${instance} ${JSON.stringify(out)}`);
|
||||
assert.equal(s.running(instance).length, 0, instance);
|
||||
// The next launch resolves from the narrowed file.
|
||||
if (after === "launches") {
|
||||
out = await s.carry(s.call("instance.launch", { instance }).request);
|
||||
assert.equal(out.done.refusal, null, JSON.stringify(out));
|
||||
const tools = bundleOf(s.running(instance)[0], "policy.json").tools;
|
||||
assert.ok(tools.includes("read"), JSON.stringify(tools));
|
||||
assert.equal(tools.includes("grep"), false, JSON.stringify(tools));
|
||||
} else {
|
||||
assert.equal(refusedAt(s, instance).code, after, instance);
|
||||
}
|
||||
await s.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("A3: a project define whose vars change after boot refuses until the next start rather than launch with the old ones", { skip, timeout: 90000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
let s = await start(t, p);
|
||||
editOrg(p, (d) => { d.projects.stack.agents.checker.vars["limits.tools"] = ["read"]; });
|
||||
reload(s);
|
||||
const copy = s.call("instances").copies["checker.stack"];
|
||||
assert.equal(copy.state, "refused");
|
||||
assert.match(copy.reason, /^project stack changed checker's vars since the bus started/);
|
||||
assert.equal(refusedAt(s, "checker.stack").code, "project-refused");
|
||||
await s.close();
|
||||
s = await start(t, p);
|
||||
const out = await s.carry(s.call("instance.launch", { instance: "checker.stack" }).request);
|
||||
assert.equal(out.done.refusal, null, JSON.stringify(out));
|
||||
const tools = bundleOf(s.running("checker.stack")[0], "policy.json").tools;
|
||||
assert.ok(tools.includes("read"), JSON.stringify(tools));
|
||||
assert.equal(tools.includes("grep"), false, JSON.stringify(tools));
|
||||
});
|
||||
|
||||
test("A3: a collision a project brings in after boot refuses the copy it collides with, naming both claimants", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const rootFile = join(p.root, "config", "agents.json");
|
||||
const root = JSON.parse(readFileSync(rootFile, "utf8"));
|
||||
root.agents["new.stack"] = { definition: "security-reviewer", vars: VARS };
|
||||
writeFileSync(rootFile, JSON.stringify(root));
|
||||
const s = await start(t, p);
|
||||
assert.equal(s.call("instances").copies["new.stack"].state, "instance");
|
||||
editOrg(p, (d) => { d.projects.stack.agents.new = { definition: "merge-gate", vars: VARS }; });
|
||||
reload(s);
|
||||
const copy = s.call("instances").copies["new.stack"];
|
||||
assert.equal(copy.state, "refused");
|
||||
assert.deepEqual(copy.claims, [{ name: "new.stack", project: null }, { name: "new", project: "stack" }]);
|
||||
assert.equal(copy.reason, "the instance name new.stack is claimed by root agent new.stack and project stack's new");
|
||||
assert.equal(refusedAt(s, "new.stack").code, "copy-refused");
|
||||
// Taking the project's define back out frees the root copy again.
|
||||
editOrg(p, (d) => { delete d.projects.stack.agents.new; });
|
||||
reload(s);
|
||||
assert.equal(s.call("instances").copies["new.stack"].state, "instance");
|
||||
});
|
||||
|
||||
test("A3: an organization instance written over a root copy after boot launches as itself, with its own role", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
assert.equal(s.call("instances").copies.gate.state, "instance");
|
||||
const file = join(p.root, "config", "businesses", "acme.json");
|
||||
const doc = JSON.parse(readFileSync(file, "utf8"));
|
||||
doc.roles.gate = { definition: "security-reviewer", vars: VARS };
|
||||
writeFileSync(file, JSON.stringify(doc, null, 2) + "\n", { mode: 0o600 });
|
||||
// A writer verb reloads the broker from the file, as any human edit would.
|
||||
const { revision } = s.call("instances");
|
||||
s.call("veto.set", { revision, op: "keep", name: "coder" });
|
||||
assert.equal(Object.hasOwn(s.call("instances").copies ?? {}, "gate"), false);
|
||||
const done = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(done.done.refusal, null, JSON.stringify(done));
|
||||
const [run] = s.running("gate");
|
||||
assert.equal(bundleOf(run, "manifest.json").definition, "security-reviewer");
|
||||
});
|
||||
|
||||
test("A3: two organizations each launch their own gate copy; runs, sessions and trails stay under each one's data root", { skip, timeout: 90000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const beta = businessDoc(p.root, "beta");
|
||||
for (const r of Object.values(beta.roles)) r.vars = { ...VARS, ...r.vars, model: r.vars?.model ?? "claude-sonnet-5-5" };
|
||||
beta.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
|
||||
beta.launch = launchDoc("chief", ["coder"], { opus: 1, sonnet: 4 });
|
||||
beta.plugins = { ...beta.plugins, harness: ["pi", "claude-code"] };
|
||||
writeJson(join(p.root, "config", "businesses", "beta.json"), beta);
|
||||
const a = await start(t, p);
|
||||
mkdirSync(join(p.root, "ob"), { mode: 0o700 });
|
||||
const b = await start(t, p, { businessId: "beta", orgRoot: join(p.root, "ob") });
|
||||
const runs = {};
|
||||
for (const [id, s] of [["acme", a], ["beta", b]]) {
|
||||
const done = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(done.done.refusal, null, JSON.stringify(done));
|
||||
const [run] = s.running("gate");
|
||||
runs[id] = run;
|
||||
assert.ok(run.runDir.startsWith(join(s.dataRoot, "launches", id) + "/"), run.runDir);
|
||||
await until(() => existsSync(join(run.runDir, "session")));
|
||||
await until(() => readFileSync(join(run.runDir, "runner.log"), "utf8").includes("claimed by run"));
|
||||
assert.deepEqual(s.call("instances").copies.gate.running, [run.run]);
|
||||
}
|
||||
assert.notEqual(runs.acme.run, runs.beta.run);
|
||||
// Neither bus, trail or data root holds the other's run.
|
||||
for (const [mine, other, s] of [["acme", "beta", a], ["beta", "acme", b]]) {
|
||||
assert.equal(trail(s.dataRoot).includes(runs[other].run), false, mine);
|
||||
assert.equal(existsSync(join(s.dataRoot, "launches", other)), false, mine);
|
||||
assert.equal(readdirSync(s.dataRoot, { recursive: true }).join("\n").includes(runs[other].run), false, mine);
|
||||
}
|
||||
assert.equal(b.running("gate").some((r) => r.run === runs.acme.run), false);
|
||||
});
|
||||
|
||||
test("A3: a root file change while the bus runs reaches no relaunch or reload; the next boot takes it", { skip, timeout: 90000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const rootFile = join(p.root, "config", "agents.json");
|
||||
writeJson(rootFile, { agentsVersion: 1, agents: { fresh: { definition: "merge-gate", vars: VARS } } }, 0o600);
|
||||
// A launch, a relaunch and a reload all keep the boot's copy set.
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const done = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(done.done.refusal, null, JSON.stringify(done));
|
||||
await until(() => s.running("gate").length === 1 && readFileSync(join(s.running("gate")[0].runDir, "runner.log"), "utf8").includes("claimed by run"));
|
||||
const { revision } = s.call("instances");
|
||||
s.call("veto.set", { revision, op: i ? "unkeep" : "keep", name: "coder" });
|
||||
assert.deepEqual(Object.keys(s.call("instances").copies).sort(), ["checker.stack", "gate", "researcher"]);
|
||||
assert.equal(refusedAt(s, "fresh").code, "unknown-role");
|
||||
await s.carry(s.call("instance.end", { instance: "gate" }).request);
|
||||
await until(() => s.running("gate").length === 0);
|
||||
}
|
||||
await s.close();
|
||||
// A restart reads the file again.
|
||||
const next = await start(t, p);
|
||||
assert.deepEqual(Object.keys(next.boot.copies.copies).sort(), ["checker.stack", "fresh"]);
|
||||
assert.equal(refusedAt(next, "gate").code, "unknown-role");
|
||||
assert.equal((await next.carry(next.call("instance.launch", { instance: "fresh" }).request)).done.refusal, null);
|
||||
});
|
||||
|
||||
test("A3: the chief can't launch a copy; only the human or a thread names one", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const chief = await s.host.bindLaunch({ business: "acme", role: "chief", run: "chief-test", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) });
|
||||
await new Client({ path: s.runtime.path, cap: chief.cap }).call("role.claim");
|
||||
for (const instance of ["gate", "checker.stack"]) {
|
||||
const out = await s.launcher.fromSession(chief.cap, instance);
|
||||
assert.deepEqual(out, { ok: false, error: "instance-not-listed" }, instance);
|
||||
}
|
||||
const unbound = await s.launcher.fromSession(chief.cap, "researcher");
|
||||
assert.deepEqual(unbound, { ok: false, error: "credential-unbound" });
|
||||
assert.equal(s.launcher.registry.sessions.length, 0);
|
||||
});
|
||||
|
||||
test("A3: a second organization boots its own copies from the same root file and shares no record, request or session with the first", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const beta = businessDoc(p.root, "beta");
|
||||
for (const r of Object.values(beta.roles)) r.vars = { ...VARS, ...r.vars, model: r.vars?.model ?? "claude-sonnet-5-5" };
|
||||
beta.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
|
||||
beta.launch = launchDoc("chief", ["coder"], { opus: 1, sonnet: 4 });
|
||||
beta.plugins = { ...beta.plugins, harness: ["pi", "claude-code"] };
|
||||
writeJson(join(p.root, "config", "businesses", "beta.json"), beta);
|
||||
const a = await start(t, p);
|
||||
mkdirSync(join(p.root, "ob"), { mode: 0o700 });
|
||||
const b = await start(t, p, { businessId: "beta", orgRoot: join(p.root, "ob") });
|
||||
assert.notEqual(a.dataRoot, b.dataRoot);
|
||||
assert.equal(a.boot.copies.org, "acme");
|
||||
assert.equal(b.boot.copies.org, "beta");
|
||||
// beta isn't excluded from sec, and has no project agent of acme's.
|
||||
assert.deepEqual(Object.keys(b.boot.copies.copies).sort(), ["gate", "researcher", "sec"]);
|
||||
const { request } = a.call("instance.launch", { instance: "gate" });
|
||||
const done = await a.carry(request);
|
||||
assert.equal(done.done.refusal, null, JSON.stringify(done));
|
||||
const [run] = a.running("gate");
|
||||
// beta's bus holds none of it: no request, no claim, no run, no session.
|
||||
assert.throws(() => b.call("instance.request", { request }), BusError);
|
||||
assert.deepEqual(b.call("instances").copies.gate.running, []);
|
||||
assert.equal(trail(b.dataRoot).includes(run.run), false);
|
||||
assert.equal(existsSync(join(b.dataRoot, "launches", "acme")), false);
|
||||
const sessions = (root) => existsSync(join(root, "sessions")) ? readdirSync(join(root, "sessions"), { recursive: true }).join("\n") : "";
|
||||
assert.equal(sessions(b.dataRoot).includes(run.run), false);
|
||||
assert.equal(b.running("gate").length, 0);
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
// #1873 A2: `mosaic agents root` and `mosaic project agents`, end to end through scripts/mosaic on a
|
||||
// bootstrapped named instance (the org CLI's own setup, org.test.mjs).
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const repo = resolve(import.meta.dirname, '../../..');
|
||||
const SECRET = 'sk-test-NOT-A-SECRET-1234';
|
||||
const ABSENT = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
|
||||
|
||||
function instance() {
|
||||
const home = mkdtempSync(join(tmpdir(), 'a2cli-'));
|
||||
const config = join(home, '.config/mosaic-atwo/config.json');
|
||||
const env = { PATH: process.env.PATH, LANG: 'C.UTF-8', TMPDIR: process.env.TMPDIR ?? tmpdir(), HOME: home, MOSAIC_CONFIG: config };
|
||||
const boot = spawnSync(join(repo, 'scripts/bootstrap.sh'), ['--instance', 'atwo'], { cwd: repo, env, encoding: 'utf8' });
|
||||
assert.equal(boot.status, 0, boot.stderr);
|
||||
const run = (args, extra = {}) => spawnSync(join(repo, 'scripts/mosaic'), args, { cwd: repo, env: { ...env, ...extra }, encoding: 'utf8' });
|
||||
const ok = (...args) => { const r = run(args); assert.equal(r.status, 0, `${args.join(' ')}: ${r.stderr}`); return r.stdout; };
|
||||
const file = join(home, 'alpha.json');
|
||||
ok('org', 'template', 'render', 'alpha', '--fixture', 'local', '--output', file);
|
||||
ok('org', 'create', 'alpha', '--from', file);
|
||||
const project = Object.keys(JSON.parse(readFileSync(join(home, '.config/mosaic-atwo/businesses/alpha.json'), 'utf8')).projects)[0];
|
||||
return { home, env, run, ok, project, configDir: join(home, '.config/mosaic-atwo') };
|
||||
}
|
||||
|
||||
test('agents root list, set, remove: each write prints its receipt, values stay out of receipts and refusals', () => {
|
||||
const { run, ok, configDir } = instance();
|
||||
assert.deepEqual(JSON.parse(ok('agents', 'root', 'list', '--json')), { revision: ABSENT, state: 'absent', agents: [] });
|
||||
assert.match(ok('agents', 'root', 'list'), /^root agents: none \(no file\)/);
|
||||
const added = JSON.parse(ok('agents', 'root', 'set', 'researcher', '--definition', 'research', '--var', 'model=claude-opus-5-5'));
|
||||
assert.equal(added.verb, 'root.agent.set'); assert.equal(added.agent, 'researcher'); assert.deepEqual(added.changed, ['added']);
|
||||
assert.equal(added.revisionBefore, ABSENT);
|
||||
const patched = run(['agents', 'root', 'set', 'researcher', '--var', 'limits.network=none', '--exclude-orgs', 'home']);
|
||||
assert.equal(patched.status, 0, patched.stderr);
|
||||
assert.deepEqual(JSON.parse(patched.stdout).changed, ['vars.limits.network', 'excludeOrgs']);
|
||||
assert.ok(!patched.stdout.includes('none') && !patched.stdout.includes('claude-opus'));
|
||||
const listed = JSON.parse(ok('agents', 'root', 'list', '--json'));
|
||||
assert.equal(listed.state, 'present');
|
||||
assert.deepEqual(listed.agents, [{ name: 'researcher', definition: 'research', model: 'claude-opus-5-5', varKeys: ['limits.network'], excludeOrgs: ['home'] }]);
|
||||
// A stale --revision refuses and the file is unchanged.
|
||||
const before = readFileSync(join(configDir, 'agents.json'), 'utf8');
|
||||
const stale = run(['agents', 'root', 'set', 'researcher', '--var', 'limits.network=api-only', '--revision', added.revisionAfter]);
|
||||
assert.equal(stale.status, 2); assert.match(stale.stderr, /revision-conflict/);
|
||||
// An invalid value refuses with the key named and the value absent.
|
||||
const bad = run(['agents', 'root', 'set', 'researcher', '--var', `model=${SECRET.repeat(10)}`]);
|
||||
assert.equal(bad.status, 2); assert.match(bad.stderr, /agents-invalid: .*model/); assert.ok(!bad.stderr.includes(SECRET), bad.stderr);
|
||||
const badJson = run(['agents', 'root', 'set', 'researcher', '--var-json', `limits.tools=${SECRET}`]);
|
||||
assert.equal(badJson.status, 4); assert.match(badJson.stderr, /var limits\.tools is not valid JSON/); assert.ok(!badJson.stderr.includes(SECRET));
|
||||
const chief = run(['agents', 'root', 'set', 'boss', '--definition', 'chief-of-staff']);
|
||||
assert.equal(chief.status, 2); assert.match(chief.stderr, /chief-of-staff can't be a root agent/);
|
||||
assert.equal(readFileSync(join(configDir, 'agents.json'), 'utf8'), before);
|
||||
// An agent run is refused before anything is read.
|
||||
for (const marker of ['MOSAIC_RUN_ID', 'MOSAIC_BUS_CAP', 'MOSAIC_AGENT_RUN']) {
|
||||
const inside = run(['agents', 'root', 'remove', 'researcher'], { [marker]: '1' });
|
||||
assert.equal(inside.status, 3, `${marker}: ${inside.stderr}`);
|
||||
}
|
||||
assert.equal(readFileSync(join(configDir, 'agents.json'), 'utf8'), before);
|
||||
const removed = JSON.parse(ok('agents', 'root', 'remove', 'researcher'));
|
||||
assert.equal(removed.verb, 'root.agent.remove'); assert.deepEqual(removed.changed, ['removed']);
|
||||
const receipts = readFileSync(join(configDir, 'agents-receipts.jsonl'), 'utf8');
|
||||
assert.equal(receipts.trim().split('\n').length, 3);
|
||||
assert.ok(!receipts.includes('claude-opus') && !receipts.includes(SECRET));
|
||||
// Usage refusals never echo option values.
|
||||
const unknown = run(['agents', 'root', 'set', 'researcher', `--${SECRET}`]);
|
||||
assert.equal(unknown.status, 4); assert.ok(!unknown.stderr.includes(SECRET));
|
||||
});
|
||||
|
||||
test('a filesystem failure prints its code and a fixed line, never a host path', () => {
|
||||
const { run, configDir, home } = instance();
|
||||
// A directory where the receipt file goes: opening it fails EISDIR, whose message holds the path.
|
||||
mkdirSync(join(configDir, 'agents-receipts.jsonl'));
|
||||
const r = run(['agents', 'root', 'set', 'researcher', '--definition', 'research']);
|
||||
assert.equal(r.status, 2, r.stderr);
|
||||
assert.match(r.stderr, /EISDIR: the operation failed; the error text is not shown/);
|
||||
assert.ok(!r.stderr.includes(home), r.stderr);
|
||||
assert.equal(existsSync(join(configDir, 'agents.json')), false);
|
||||
});
|
||||
|
||||
test('a missing, malformed or unclaimed instance config refuses before anything is read or written', () => {
|
||||
const verbs = [['agents', 'root', 'set', 'researcher', '--definition', 'research'], ['agents', 'root', 'list'], ['project', 'agents', 'work', '--org', 'alpha', 'exclude', 'coder']];
|
||||
const check = (env, dir, why) => {
|
||||
for (const args of verbs) {
|
||||
const r = spawnSync(join(repo, 'scripts/mosaic'), args, { cwd: repo, env, encoding: 'utf8' });
|
||||
assert.equal(r.status, 3, `${why} ${args.join(' ')}: ${r.stderr}`); assert.match(r.stderr, /system config problem/);
|
||||
}
|
||||
assert.equal(existsSync(join(dir, 'agents.json')), false, why);
|
||||
assert.equal(existsSync(join(dir, 'agents-receipts.jsonl')), false, why);
|
||||
};
|
||||
// Missing: the instance's config directory exists, its config.json does not.
|
||||
const home = mkdtempSync(join(tmpdir(), 'a2cfg-'));
|
||||
const dir = join(home, '.config/mosaic-atwo');
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
const env = { PATH: process.env.PATH, LANG: 'C.UTF-8', TMPDIR: process.env.TMPDIR ?? tmpdir(), HOME: home, MOSAIC_CONFIG: join(dir, 'config.json') };
|
||||
check(env, dir, 'missing');
|
||||
writeFileSync(join(dir, 'config.json'), '{', { mode: 0o600 });
|
||||
check(env, dir, 'malformed');
|
||||
// Unclaimed: a bootstrapped instance whose data root marker names another instance.
|
||||
const booted = instance();
|
||||
const { dataRoot } = JSON.parse(readFileSync(booted.env.MOSAIC_CONFIG, 'utf8'));
|
||||
writeFileSync(join(dataRoot, 'instance.json'), JSON.stringify({ instanceVersion: 1, name: 'other' }));
|
||||
check(booted.env, booted.configDir, 'marker');
|
||||
});
|
||||
|
||||
test('the default instance refuses the agent level verbs', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'a2def-'));
|
||||
const env = { PATH: process.env.PATH, LANG: 'C.UTF-8', TMPDIR: process.env.TMPDIR ?? tmpdir(), HOME: home };
|
||||
for (const args of [['agents', 'root', 'list'], ['project', 'agents', 'work', '--org', 'alpha', 'list']]) {
|
||||
const r = spawnSync(join(repo, 'scripts/mosaic'), args, { cwd: repo, env, encoding: 'utf8' });
|
||||
assert.equal(r.status, 2, r.stderr); assert.match(r.stderr, /named instance/);
|
||||
}
|
||||
assert.equal(existsSync(join(home, '.config/mosaic-dev/agents.json')), false);
|
||||
});
|
||||
|
||||
test('project agents: set, narrow, exclude and remove print receipts; a widening narrow and the chief refuse', () => {
|
||||
const { run, ok, project, home } = instance();
|
||||
ok('agents', 'root', 'set', 'researcher', '--definition', 'research');
|
||||
ok('agents', 'root', 'set', 'scout', '--definition', 'research');
|
||||
const p = (...args) => ['project', 'agents', project, '--org', 'alpha', ...args];
|
||||
assert.deepEqual(JSON.parse(ok(...p('list', '--json'))).agents, []);
|
||||
const defined = JSON.parse(ok(...p('set', 'checker', '--definition', 'fact-checker', '--var', 'model=claude-sonnet-5-5')));
|
||||
assert.deepEqual({ ...defined, revisionBefore: undefined, revisionAfter: undefined, transaction: undefined },
|
||||
{ verb: 'project.agents.set', organization: 'alpha', project, agent: 'checker', form: 'define', revisionBefore: undefined, revisionAfter: undefined, transaction: undefined });
|
||||
assert.notEqual(defined.revisionBefore, defined.revisionAfter);
|
||||
assert.ok(!JSON.stringify(defined).includes(home), 'no host path in the receipt');
|
||||
assert.equal(JSON.parse(ok(...p('narrow', 'researcher', '--var', 'limits.network=none'))).form, 'narrow');
|
||||
assert.equal(JSON.parse(ok(...p('exclude', 'scout'))).form, 'exclude');
|
||||
assert.deepEqual(JSON.parse(ok(...p('list', '--json'))).agents, [
|
||||
{ name: 'checker', form: 'define', definition: 'fact-checker', varKeys: ['model'] },
|
||||
{ name: 'researcher', form: 'narrow', varKeys: ['limits.network'] },
|
||||
{ name: 'scout', form: 'exclude' },
|
||||
]);
|
||||
const file = join(home, '.config/mosaic-atwo/businesses/alpha.json');
|
||||
const before = readFileSync(file, 'utf8');
|
||||
const widen = run(p('narrow', 'scout', '--var', `model=${SECRET}`));
|
||||
assert.equal(widen.status, 2); assert.match(widen.stderr, /can only narrow limits\.tools, limits\.network, limits\.authority, not "model"/); assert.ok(!widen.stderr.includes(SECRET));
|
||||
const chief = run(p('exclude', 'chief'));
|
||||
assert.equal(chief.status, 2); assert.match(chief.stderr, /project-agents-invalid/);
|
||||
const stale = run(p('remove', 'checker', '--revision', defined.revisionBefore));
|
||||
assert.equal(stale.status, 2); assert.match(stale.stderr, /revision-conflict/);
|
||||
const inside = run(p('remove', 'checker'), { MOSAIC_RUN_ID: 'r1' });
|
||||
assert.equal(inside.status, 3);
|
||||
assert.equal(readFileSync(file, 'utf8'), before);
|
||||
for (const name of ['checker', 'researcher', 'scout']) assert.equal(JSON.parse(ok(...p('remove', name))).form, 'remove');
|
||||
assert.deepEqual(JSON.parse(ok(...p('list', '--json'))).agents, []);
|
||||
const missing = run(['project', 'agents', 'nope', '--org', 'alpha', 'exclude', 'scout']);
|
||||
assert.equal(missing.status, 2); assert.match(missing.stderr, /project-not-found/);
|
||||
});
|
||||
@@ -100,9 +100,15 @@ function sessionPairs(pairs) {
|
||||
// Safety net for a removed-outer-bound change. It is fixture cleanup, never
|
||||
// product supervision. The guard reads only fixture records and checks sid
|
||||
// immediately before every kill. Normal outer-bound cases return before it.
|
||||
export function fixtureGuard(t,files,milliseconds=9500) {
|
||||
const program=`const fs=require('node:fs');setTimeout(()=>{for(const file of JSON.parse(process.argv[1])){let rows=[];try{rows=fs.readFileSync(file,'utf8').trim().split(String.fromCharCode(10))}catch{}for(const row of rows){const [pid,sid]=row.trim().split(' ').map(Number);if(!(pid>0&&sid>0))continue;try{const raw=fs.readFileSync('/proc/'+pid+'/stat','utf8'),f=raw.slice(raw.lastIndexOf(') ')+2).split(' ');if(Number(f[3])===sid&&f[0]!=='Z')process.kill(pid,'SIGKILL')}catch{}}}},Number(process.argv[2]));`;
|
||||
const child=spawn(process.execPath,['-e',program,JSON.stringify(files),String(milliseconds)],{stdio:'ignore'});
|
||||
export function fixtureGuard(t,files,milliseconds=9500,startFile) {
|
||||
// P10 already records the instant its observed probe starts. Arm its safety
|
||||
// net there, so unrelated host-fixture setup cannot consume the probe budget.
|
||||
// Callers without an observer retain the original immediate-start behavior.
|
||||
const program=`const fs=require('node:fs');
|
||||
function cleanup(){for(const file of JSON.parse(process.argv[1])){let rows=[];try{rows=fs.readFileSync(file,'utf8').trim().split(String.fromCharCode(10))}catch{}for(const row of rows){const [pid,sid]=row.trim().split(' ').map(Number);if(!(pid>0&&sid>0))continue;try{const raw=fs.readFileSync('/proc/'+pid+'/stat','utf8'),f=raw.slice(raw.lastIndexOf(') ')+2).split(' ');if(Number(f[3])===sid&&f[0]!=='Z')process.kill(pid,'SIGKILL')}catch{}}}}
|
||||
function arm(){setTimeout(cleanup,Number(process.argv[2]));}
|
||||
if(!process.argv[3])arm();else{const poll=setInterval(()=>{let text='';try{text=fs.readFileSync(process.argv[3],'utf8')}catch{}if(/^begin [0-9]+[.][0-9]{2}\\n/.test(text)){clearInterval(poll);arm();}},10);}`;
|
||||
const child=spawn(process.execPath,['-e',program,JSON.stringify(files),String(milliseconds),...(startFile?[startFile]:[])],{stdio:'ignore'});
|
||||
const stop=async()=>{
|
||||
if(child.exitCode!==null||child.signalCode!==null)return;
|
||||
const exited=new Promise(resolve=>child.once('exit',resolve));
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fixtureGuard } from './case-sudo-fixture.mjs';
|
||||
|
||||
test('fixture cleanup guard starts its budget at the probe marker', async t => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'guard-'));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const records = join(dir, 'pids'), startFile = join(dir, 'probe');
|
||||
writeFileSync(startFile, '');
|
||||
const child = spawn(process.execPath, ['-e', 'setInterval(()=>{},1000)'], { stdio: 'ignore' });
|
||||
const exited = once(child, 'exit');
|
||||
t.after(() => { if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); });
|
||||
const raw = readFileSync(`/proc/${child.pid}/stat`, 'utf8');
|
||||
const sid = Number(raw.slice(raw.lastIndexOf(') ') + 2).split(' ')[3]);
|
||||
writeFileSync(records, `${child.pid} ${sid}\n`);
|
||||
const stop = fixtureGuard(t, [records], 100, startFile);
|
||||
// An empty marker is setup, not the start of the observed probe. This wait
|
||||
// exceeds the cleanup budget deliberately; no production bound is changed.
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
assert.equal(child.signalCode, null, 'setup must not consume the probe cleanup budget');
|
||||
writeFileSync(startFile, 'begin 100.00\n');
|
||||
const terminal = await Promise.race([exited, new Promise((_, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('armed fixture guard did not clean up')), 3000);
|
||||
timer.unref();
|
||||
})]);
|
||||
assert.deepEqual(terminal, [null, 'SIGKILL']);
|
||||
await stop();
|
||||
});
|
||||
@@ -429,7 +429,7 @@ for(const fault of ['inner','outer','overflow'])test(`row253 C4 P10 ${fault} rea
|
||||
assert.equal(baseline.status,0,baseline.out+baseline.err);
|
||||
assert.deepEqual(await pollFixtureGone(files),[],'baseline leaves no session');
|
||||
}
|
||||
const capture=cleanupFixture(t,files);const stop=fixtureGuard(t,files,7000);
|
||||
const capture=cleanupFixture(t,files);
|
||||
if(fault==='inner')put(join(h.hc,'peer-hang'),'');
|
||||
if(fault==='outer'){put(join(h.hc,'stall-probe'),'');put(join(h.hc,'stall-output'),'200 997 997\n');}
|
||||
if(fault==='overflow'){put(join(h.hc,'peer-output'),'200 997 997\n'+'X'.repeat(256));put(join(h.hc,'peer-status'),'0');}
|
||||
@@ -440,6 +440,7 @@ for(const fault of ['inner','outer','overflow'])test(`row253 C4 P10 ${fault} rea
|
||||
put(join(h.dir,'selector.sh'),read(join(REPO,'scripts','selector.sh')),0o644);
|
||||
assert.equal(removeP10Observers(read(observedScript)),read(SCRIPT),'observer reversal equals a fresh shipped script read');
|
||||
}
|
||||
const stop=fixtureGuard(t,files,7000,timingFile);
|
||||
const start=performance.now(),r=run(h,timingFile?{ROW299_TIMING_FILE:timingFile}:{},undefined,observedScript),seconds=(performance.now()-start)/1000;capture();
|
||||
assert.equal(r.status,0,r.out+r.err);
|
||||
const reason=fault==='inner'?'inner-timeout':fault==='outer'?'outer-timeout':'output-limit';
|
||||
|
||||
Loaded 100 of 240 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user