Compare commits

...
22 Commits
Author SHA1 Message Date
jason.woltje 4a122699a0 fix: preserve tester session identity through cleanup (#1873) 2026-11-11 08:22:31 -06:00
jason.woltje f382699d47 fix: bound tester supervisor cleanup and verify fast sessions (#1873) 2026-11-11 05:13:47 -06:00
jason.woltje 5e93356cfa fix(authority): close chief grant and root fire review gaps (#1873) 2026-11-11 04:19:33 -06:00
jason.woltje 89205a6163 feat(authority): resolve hire, fire and role changes through pinned human decisions (#1873) 2026-11-11 04:19:33 -06:00
jason.woltje 6342940634 test(cli): clarify cleanup timing limits and namespace failures (#1873) 2026-11-11 03:49:22 -06:00
jason.woltje cfd7e612d3 test(cli): bound supervisor cleanup with a fixed process inventory (#1873) 2026-11-11 03:20:27 -06:00
jason.woltje afe131444c test(cli): retain default service template coverage (#1873) 2026-11-11 02:34:50 -06:00
jason.woltje 0b52249cc3 test(cli): measure legacy and query inner timers at the supervisor (#1873) 2026-11-11 02:11:48 -06:00
jason.woltje b7c9eee603 fix(organization): definition writes go through the bus with the held-secret check (#1873)
Review P1: the CLI wrote definitions with a no-op secret check. setDefinition
now refuses definition-secret-check-required when a write that adds or changes
an entry has no assertClean. New bus verbs definition.import|invent|edit|remove
run the writes for the business's human at the cli with the credential
store's assertClean; the tester, agents and readers refuse. The CLI's
definition writes call the bus and map its codes to messages that never
quote the definition.
2026-11-11 01:15:12 -06:00
jason.woltje 1d07df0d61 feat(organization): org definitions, template hash and diff reads, definition verbs (#1873)
Roles round 2, slice T1 (roles-and-rails design sections 4, 5 and 13).

- The org file's definitions key: each entry {rev, from?, role, instructions,
  personality?} passes checkComposition; a bad entry refuses the whole file,
  naming the definition and key, never a value.
- org:<name> instance references resolve to the file's definition; a missing
  one refuses definition-missing, naming the instance. Template names are
  unchanged.
- Template identity is the name plus a sha256 over the template JSON and its
  contract; templateHashes reads the current hash for each template.
- The writer's setDefinition is the only path that changes definitions: it
  checks the composition, bumps rev, refuses agent actors, and refuses
  definition-in-use for a definition an instance names (A4 adds the change
  decision). write() refuses organization-definitions-protected.
- Definition verbs import, edit, invent and remove need org.hire
  (definition.*.console in VERB_RIGHTS) and refuse every agent caller.
- The diff read lists the primitives that changed and a line diff of the
  instructions. It applies nothing.
- CLI: mosaic org definitions templates|list|show|diff|import|invent|edit|remove.
2026-11-11 01:15:12 -06:00
jason.woltje be698e602b test(cli): json-redaction pins follow the label refusals
The review round changed the config-command relay and the org document
refusal to name the file by label. cli/tests/json-redaction.test.mjs still
required the host path in those two CLI refusals. They now require the
label and the path's absence. The direct scripts/mosaic-config.mjs checks
and the loadSystem check (PL1, deferred) still require the path.
2026-11-11 01:04:15 -06:00
jason.woltje d085b16136 fix(organization,cli): review round: raw fs and git errors, console entry, config, document and --vars refusals name labels
Sol review (org-redact5, changes 6):
- org-service: Node filesystem errors become "cannot <syscall> <label> (<CODE>)";
  code, syscall, deleteStep and deleteReceipt stay. git failures name the verb
  and exit code, never git's stderr.
- org-console: consoleStop names "another checkout", not the entry path. Exit 3.
- config-command: the system config is named by where it came from. Exit 3.
- cli org: documents and raw fs errors are named by kind and code. Exit 2.
- instances: --vars file named by flag and kind. Exit 4.
- hosts: a refused command is named by the basename refusedCommand matched.
- role.mjs (T1): the change is back to the readJsonFile label only.
Sentinel tests for each; pins in last-values, rename-b-commands, org-recovery.
2026-11-11 01:04:15 -06:00
jason.woltje 4c8bf9ca4e fix(organization,cli): refusals name the file by label, never by host path
PR #1916 rule: a refusal names the field or file, never a config value
or a host path. readJsonFile's three refusals and parseJsonFileText now
name the file by its `what` label only. Callers whose label alone is
ambiguous add a name that already passed a strict pattern (business id,
role name, org id, turn id, project key) or a fixed path under the data
root. Exit codes, refusal codes and check order are unchanged.

organization: util, access, project, config-command, library-seats,
org-service deleteOrg receipt, agent-levels regex; T1 files role.mjs and
organization-config.mjs change only the readJsonFile label.
cli: access lock, accounts owner, host state/lock, notifier journal,
notify-config, run-evidence, dev-fixture, git-socket, org-bus, org-console.
discord: turn record label names the turn id.

Sentinel tests (sk-test-NOT-A-SECRET-1234 as a path component) cover each
changed refusal and fail on BASE; old path pins updated.
2026-11-11 01:04:15 -06:00
jason.woltje e588fb32cb test(cli): band the P10 inner timer at the supervisor, not around cleanup (#1873)
The row299 root-side pair (mos-1409) spans supervisor start-up and
clean_session, which scans every /proc entry on the host. With about
1,850 processes that adds 0.6 to 1.6 s, so the unchanged inner probe read
2.5 to 3.9 s and failed the [1.25, 2.75) band, while the 1 s inner-timer
mutant read 2.06 to 2.38 s and passed. Widening the bound alone would
have kept that mutant alive.

A second observer pair, also confined to the private test copy, brackets
only the inner timer: begin just before the supervisor starts its sleep,
end at the start of finish(). It writes only for the SO_PEERCRED peer and
at most one pair. The unchanged band now applies to that interval
(2.01 s on base, 1.00 s and 3.00 s for the 1 s and 3 s mutants, so both
fail). The root pair remains required and must contain the timer pair;
probe-inner-timeout already places it before the outer bound.
2026-11-11 00:30:10 -06:00
jason.woltje e3577e9d31 fix(case2): give the case2 fixture host the request-host ops (#1873)
test-case2.sh failed 8 of 21 steps on base from S3b on. Bisect names
f23a998ee (#797, "close project archive admission races") as the first
bad commit: it made every launch call request.admission and updated each
in-process launcher fixture except scripts/case2/launch.mjs. The fixture
host refused the op with invalid-host-request, so no launch started; the
S6/S7 "unauthenticated" and S8/S10 reds followed from the missing launch
capability, not from a separate session change.

The fixture now spreads requestHostOps from packages/cli/tests, the shared
map every other fixture uses, so it tracks the production protocol.
2026-11-11 00:30:10 -06:00
jason.woltje 99ce579e3c fix(plugins): keep imports on the verified package pin (#1873) 2026-11-11 00:11:13 -06:00
jason.woltje b44b14c7df feat(plugins): install pinned instance packages and declared tools (#1873) 2026-11-11 00:11:13 -06:00
jason.woltje 85858938fc docs: design credential bindings for agent copies 2026-11-10 23:52:29 -06:00
jason.woltje 6415e81ee7 docs(run-sheet): check config directory ancestors before accounts init (#1873)
mosaic accounts init refuses accounts-unsafe since #1919 when a folder above the
config directory is writable by another user. Step 3 now gives the read-only check.
2026-11-10 23:43:55 -06:00
jason.woltje 15225c5e1c test(webui): verify applied light overrides and the U5 shot recipe (#1873) 2026-11-10 23:40:06 -06:00
jason.woltje cb9580f6fb fix(webui): explain conflicts that reopen a read-only task (#1873) 2026-11-10 23:40:06 -06:00
jason.woltje c0e08f81ea fix(webui): reopen Archive conflicts on the current task (#1873) 2026-11-10 23:40:06 -06:00
155 changed files with 5862 additions and 1392 deletions

No files matched your search

+12 -5
View File
@@ -42,9 +42,15 @@ green at every step. Not production software — a proven foundation.
rewritten, only pruned via `prune` with a receipt.
6. **Fail closed**: missing or invalid config/policy refuses the operation.
Never improvise around a refusal; diagnose it.
7. **Policy**: missions govern tasks (least-privilege intersection — a task
narrows, never widens). Role authority is declared in `roles/` and changes
only via reviewed commits.
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.
8. **Git**: commit only after applicable suites are green. Work on the
owner-authorized `refactor` branch; never force-push. Push remains an explicit
act. Do not merge into `next` or `main` without separate authorization.
@@ -104,7 +110,8 @@ Finish all independent work first, then ask one focused question only when:
Repository gates still apply. In particular, a successful implementation or a
broad request to “finish” does not by itself authorize push, merge, deployment,
release, production changes, policy/role expansion, or access to secrets. Perform
release, production changes, policy/role expansion, or access to secrets. An approved
hire decision is that authority for the one agent it names. Perform
such an action only when the established plan explicitly includes it. If blocked,
report the exact boundary, what is complete, the recommended resolution, and the
specific action that will resume; do not use “waiting for confirmation” as a
@@ -206,7 +213,7 @@ Full reference — usage, fields, exit codes, safety notes:
- `LAYERS.md` — implemented vs deferred layers
- `docs/SESSIONS.md` — session registry
- `adapters/README.md` — the harness adapter contract
- `roles/` — role contracts (conductor, future agent/coder/reviewer)
- `roles/` — shipped role templates and the conductor policy
## Recovery rule
+7 -1
View File
@@ -442,7 +442,13 @@ stack picks or invents a subject: the subjects come from `console.sso`, which a
`docs/plans/2026-11-10_accounts-design.md`. Jason places the client secrets (section 9).
3. Run `mosaic accounts init --name <name>` as `mosaic`. This is a human verb, run from a human shell and
never by an agent. It creates the one operator account from the configured `console.sso` subjects and
refuses if `accounts.json` exists.
refuses if `accounts.json` exists. It also refuses `accounts-unsafe` when any folder above the config
directory is not root's or `mosaic`'s, or is group or other writable without the sticky bit (#1919).
Check first, read-only: every line printed by
`p=/srv/mosaic/.config/mosaic-dev; while [ "$p" != / ]; do stat -c '%U %a %n' "$p"; p=$(dirname "$p"); done; stat -c '%U %a %n' /`
names `root` or `mosaic`, and its mode has no group or other write bit unless it starts with 1 (sticky).
The config directory itself is `mosaic`'s with no group or other write bit. A group-writable `.config`
refuses on purpose; fix the mode, never the check.
4. Provision the dedicated uid. `mosaic` must keep section 1's shape: a non-login shell, the only member
of its primary group, and owner of its config directory and data root. An accounts instance never runs
under a human or development seat uid, and never under the `webui-service.sh` user units.
@@ -0,0 +1,450 @@
# Credential binding for agent copies
Date: 2026-11-11 UTC. Lane `org-cred-bind-design`, issue #1873.
Status: proposed, for Jason's approval before implementation. Doc only.
Baseline: `6415e81ee75a6783b889b67b985f61825f9a074e` (`origin/refactor`).
All current-code citations below refer to that commit. References to other
designs describe proposed policy, not a claim that it is implemented.
## 1. Outcome and boundaries
An owner can approve a credential binding for one root or project agent copy
inside one organization. A launch uses only that copy's own secret slot,
with verified service permissions bounded by its frozen role and the
organization's narrower approval. No agent can bind credentials for itself
or another agent. Missing, stale or uncertain authority refuses launch and
credential use. Copies in other organizations remain independent.
This proposes a writer, schema and enforcement changes for later slices. It
changes no code, right, role, contract, secret or running instance. Host and
model credentials remain on their existing grant and proxy paths. There is
no migration of named-agent credentials, no root-wide secret binding, no
shared token pool, and no new plugin service in the first build.
Owner direction requires each non-assistant agent's credentials to stay
inside its organization even when its definition comes from root
(`docs/plans/2026-11-09_unified-workspace-owner-direction.md:124-131`). A3
explicitly deferred binding and its two-org thread-copy test
(`/mnt/storage/scratch/mosaic-lead/reports/org-a3-build.md:52,216-217`).
## 2. Current behavior, traced before the proposal
| Step | Current behavior and evidence |
| --- | --- |
| Declare requirements | `checkCredentials` accepts each known service once and validates its scope shape. Gitea uses category levels; Vikunja uses route-group verbs; host has no scopes (`packages/organization/src/role.mjs:100-143`). `admin` and `all` are outside the grantable Gitea categories (`packages/organization/src/vocabulary.mjs:187-191`). |
| Declare a named instance | `roles.<instance>.credentials` must reference exactly the role's services, except host grants and the chief's Gitea exception when no library exists. Vikunja also requires a unique bot identity (`packages/organization/src/organization-config.mjs:50-59,124-167`). |
| Locate the slot | `parseCredentialRef` accepts exactly one of `store`, `file` or `env`. `{store:true}` derives the file from config directory, business, agent and service; it does not accept another agent's store path. Named agents can still use external file and environment references (`packages/organization/src/credentials.mjs:23-59`). This lane proposes only the store form for copies. |
| Resolve launch inputs | `resolveInstance` takes the entry from the validated organization, intersects tools/network/actions with limits, then returns its non-host credential references and tracker metadata (`packages/organization/src/resolve.mjs:34-69,81-95`). These limits do not compute a credential scope intersection. |
| Load secrets | Runtime indexes references by `business/role` and sets the store root to the agents secret directory (`packages/bus/src/runtime.mjs:333-345,378-379`). `Credentials` loads those references into its private map (`packages/bus/src/credentials.mjs:130-165`). It derives its store filename from business, agent and service (`packages/bus/src/credentials.mjs:351-355`). File reads reject symlinks, wrong ownership/mode and repository/data-root placement (`packages/bus/src/credentials.mjs:357-415`). |
| Use secrets | `Credentials.useFor` derives business and role from the session, rather than a caller-supplied target. Missing tokens refuse `credential-unavailable`; Vikunja expiry refuses `credential-expired` (`packages/bus/src/credentials.mjs:597-624`). The task adapter uses that session-bound path (`packages/tasks/src/adapter.mjs:84-88`). |
| Issue and revoke | The broker requires a human, with an explicit tester-verb exception; it checks that the agent exists and prevents delete while ordinary or thread claims remain (`packages/bus/src/broker.mjs:6212-6225`). The credential writer reads only `document.roles[agent].credentials`; issue writes a store reference, revoke drops memory/file before writing or recording the receipt (`packages/bus/src/credential-verbs.mjs:16-51`). |
| Record writes | The organization writer takes the shared registry lock, then the organization's history `write.lock`, validates the revision and document, records a prepared change and publishes the file (`packages/organization/src/organization.mjs:143-159,175-220,278-340`; `packages/organization/src/org-registry.mjs:73-101`). Its `pathsOnly` option records changed field paths and presence without values (`organization.mjs:276-278,324-327`). |
| Materialize copies | Root and project entries explicitly refuse `credentials`, `tracker`, `identity`, `publish`, `holder` and `hosts` (`packages/organization/src/agent-levels.mjs:18-41,53-59,105-115`). Copy names preserve root names or append project names. Collisions refuse both claimants; org instances shadow copies. Every required non-host service makes a copy `unbound` (`packages/organization/src/agent-copies.mjs:36-53,73-104`). An unbound copy never joins the role map and launch refuses `credential-unbound`, naming missing services (`agent-copies.mjs:223-245,279-287`; `packages/cli/src/launcher.mjs:361-375`). |
| Guard launch races | Launch checks against a fresh organization revision, then final `request.admission` compares it again after async preparation (`packages/cli/src/launcher.mjs:357-364,802-813`; `packages/bus/src/runtime.mjs:636-661`). This revision covers organization bytes, not role file bytes. A3 records that limitation explicitly (`/mnt/storage/scratch/mosaic-lead/reports/org-a3-build.md:215`). |
Important limit: validating a slot reference does not prove the remote
token's permissions. For non-host/non-model services, `Credentials.#format`
checks token syntax and length, not remote scopes
(`packages/bus/src/credentials.mjs:448-451`). Existing Gitea minting has a
fixed seat scope list, not a per-copy intersection
(`packages/bus/src/gitea-grant.mjs:27-30,262-269`). Vikunja startup has
specific negative permission probes, not a complete per-composition scope
proof (`packages/tasks/src/startup.mjs:30-55`). The new build must close
these gaps for copies; a JSON field that asserts scopes is insufficient.
## 3. Choice and storage
Considered three approaches:
1. Add credentials to root/project definitions. Rejected: a root definition
would carry a per-org concern and invite cross-org reuse.
2. Write synthetic copies into `roles`. Rejected: this conflicts with org
shadowing and turns a boot copy into a persistent named instance.
3. Add an org-local binding overlay and reuse the existing per-agent store.
Recommended. It keeps copy definitions secret-free, preserves shadowing,
and gives bindings the same revision fence as other org settings.
The authoritative metadata lives in the existing organization file,
`<configDir>/businesses/<org>.json`, under a proposed top-level
`copyBindings` key. It is not written into `agents.json` or `roles/`.
An absent key means no bindings. The writer stores one record per concrete
copy instance; the example below is illustrative schema, not current JSON:
```json
"copyBindings": {
"writer.site": {
"copyId": "<random 128-bit lowercase hex>",
"orgIncarnation": "<registry incarnation>",
"source": { "level": "project", "name": "writer", "project": "site" },
"definition": { "ref": "org:release-writer", "rev": 3, "sha256": "<digest>" },
"generation": 1,
"state": "bound",
"services": {
"gitea": {
"slot": "self",
"scopes": ["read:repository"],
"resources": { "projects": ["site"] },
"rotateBy": "2026-12-01",
"grantId": "<opaque service grant id>",
"grantGeneration": 1
}
},
"decision": "<authority decision id>"
}
}
```
`slot: "self"` means exactly `(org, concrete instance, service)`. It is a
selector for today's derived slot, not an arbitrary string slot name. The
record contains no path, environment name, token, token fragment or token
hash. Grant ids identify authorization records, never encode token bytes.
Secret values stay exclusively in the existing private credential store
and trusted service-call memory, never in metadata, evidence, bundles,
decision text or errors. The symbolic paths in this design describe source
architecture; runtime responses never emit resolved host paths.
`source` includes the level and original name/project, so dotted-name
collisions cannot silently attach an old binding. `copyId` is created on
first approval and retired on fire, shadowing, source removal or replacement.
`generation` increases for every binding change or revocation and never
resets while that copyId exists. An unbound tombstone remains after unbind.
A new copyId never adopts an old slot file. Name reuse requires verified
old-slot cleanup and newly verified credentials before activation.
For a root copy, source.project is null and its definition is the frozen
root definition identity. For a project copy it names the org definition
or template identity. A definition hash includes composition and contract;
the applicable instruction/personality revision is pinned too. Import
provenance (`from.sha256`) is not the customized definition's current hash.
Vikunja metadata additionally contains the existing bot identity shape
(`tracker: {bot, botId}`) at the copy record level. It must pass the same
org-wide uniqueness check as named instances, including other copies.
No service binding implicitly grants identity, publication, host enrollment
or project access. Existing identity/publish requirements remain separate.
### Writer and lock order
Extend `organizationWriter` with dedicated trusted methods
`applyCopyBindingDecision` and `revokeCopyBinding`; names are proposed API.
Only the authenticated broker/Console owner path invokes these methods.
Generic document writes and root/project writers must reject changes to
`copyBindings`, even if a submitted document validates. An actor string,
decision id or hash supplied by an agent is never authorization.
Reuse the shared registry lock `<configDir>/org-runtime.lock`, then the
organization lock `<configDir>/businesses/.history/<org>/write.lock`.
Do not introduce a second competing metadata file or lock. Reuse the
writer's revision conflict, prepared/committed receipt and paths-only
evidence machinery. Under the locks, establish fresh active org incarnation,
owner identity, expected document revision and decision digest. Perform
the final checks in the publication critical section, not only in HTTP.
Do not hold filesystem locks across remote service calls or waits for runs
to stop. Before such work, persist a non-active pending operation with a
unique id and expected generations. Reacquire the locks, re-read and compare
all identities before publishing. A competing write invalidates preparation.
Use the same operation id for idempotent recovery; a prepared or uncertain
operation cannot produce a usable binding or resurrect the prior token.
## 4. Authority, decisions and deployed ownership
Use the existing grantable `org.hire` right as the authority-change entry
right. It already exists, defaults to owner yes/member no, and maps
`hire.resolve.console` to `org.hire`
(`packages/organization/src/org-rights.mjs:6-26,67-69`). Do not add an
`org.credentials` right that would imply delegable token-grant authority.
The right is necessary but not sufficient: any decision containing a
credential line requires the current owner of the target org, even if a
member holds `org.hire` or `decide`. Map credential resolution to an
explicit owner check as well as the right check, rechecked at commit.
The owner-only credential read stays owner-only; `board.read` gets at most
the existing copy's missing-service status, never a binding record.
Bind and unbind are **authority-change decisions**, represented by the
hire model's `op: "change"`, with a binding-specific payload. The
decision names source, copyId (or new-copy proposal), org incarnation,
definition revision/hash, before/after service scopes and resources, and
expected binding generation. The broker computes credential lines and
the whole-decision digest. Approval consumes the decision once. Dropping,
narrowing or replacing a line invalidates the old digest and requires
confirmation of the new digest; widening always raises a new decision.
This reuses hire-as-a-decision instead of creating a second approval
model. It follows the roles-and-rails design's credential ruling,
owner restriction, digest, seven-day expiry, one-step human-originated
decision, and post-approval binding steps
(`docs/plans/2026-11-10_roles-and-rails-design.md:35,232-310`). The chief
may propose a binding only through that decision path, never by naming a
slot, supplying a token or calling the writer. Other agents cannot raise
this authority-change subtype. No agent resolves it, including the chief,
an arbiter, its subject, or a different copy of the same definition.
This is an authority change under system rails R1, R5, R7 and R8 and the
role's credential rail. Default autonomy is **in the loop**. As required by
the existing design, credential bindings do not move to near or out of the
loop at all without a new owner ruling
(`docs/plans/2026-11-10_roles-and-rails-design.md:90-119,416-425,445-463`).
Unbind uses the same human decision record, including a one-step
owner-originated decision. Emergency **revoke** remains an owner-only
immediate verb: it only removes use, must not wait for a chief proposal,
and records the authority reduction. Revoke never issues a token or changes
an unbound binding to bound.
A root-level definition change still needs the instance owner. Binding one
root copy inside org O needs O's owner and grants nothing in another org.
Being an instance operator or root-business owner alone does not make a
person O's owner. Accounts design separates these principals explicitly
(`docs/plans/2026-11-10_accounts-design.md:314-349`). No account-only session
falls back to a human/root transport. All Console mutations retain Origin,
session, fresh ownership and confirmation fences.
The host remains the trust boundary. Host root and the instance uid can
already change the instance and its secrets; this proposal does not claim
protection from either, or from a sandbox escape to that uid. The deployed
instance uid must remain separate from human/development seats
(`docs/plans/2026-11-10_accounts-design.md:887-936`).
## 5. Permission calculation and proof
For every service V, compute:
`effective(V) = role-declared(V) intersect org-approved-binding(V)`.
The binding is the org's per-copy narrowing. Omitted services are unbound,
not permission to use another source. Unknown services/scopes refuse; they
are never ignored. The requested scopes must already be a subset of the
role. Do not silently trim a broader request and report that it succeeded.
Resources also intersect the org's current admitted project set and the
copy's union. A project copy may access its project only. A root copy may
access only the approved projects that still admit it. An empty intersection
allows no service operations and leaves a required service unbound.
Scope comparison is semantic: Gitea read is narrower than write in the same
category, categories are independent, and neither admin nor all is allowed.
Vikunja compares each route-group/verb pair. Missing categories/groups mean
no permission; there is no wildcard. All adapters must apply their service's
registered semantics, never compare scope strings lexicographically.
### At proposal, approval and bind
1. Resolve the concrete copy from the target org's boot snapshot and current
project union. Refuse missing, shadowed, colliding, excluded or fired
copies. Never create `roles[copyName]` to make the operation succeed.
2. Validate the role through the core composition validator and current
enabled registry. Read T1's definition revision/hash, or the frozen root
role identity. Verify org active state and incarnation. Compute all
credential lines from these inputs, not from chief-supplied summaries.
3. Check every requested service/scope/resource against that definition and
org. Verify the resolver is the fresh org owner. The owner cannot override
a system rail or widen a binding past the approved role; changing the role
requires its own approved change first.
4. Obtain a dedicated service credential for this exact org/copy/service.
Trusted service adapters must establish both token scopes and remote
identity resource grants. Prove the allowed projects/repos at the service,
not just a local `resources` list. A root-wide or cross-org bot account is
unacceptable even if its token is stored in a per-org directory.
5. Accept a token only after trusted live introspection verifies its scopes,
service account, permitted resources and expiry against this operation,
or after a reviewed managed mint path produces and verifies that exact
bounded grant. An owner-entered assertion, copied grant id, token format,
or a few successful/negative probes are not proof of complete permissions.
If the service/version cannot prove the bound, refuse
`credential-scope-unverified`. Ship that refusal rather than an unsafe
compatibility mode. This is a required service-adapter build item.
6. Stage the secret privately in the existing store with the binding inactive.
Recheck decision digest, definition hash, org incarnation, document
revision and slot generation after remote work. Only then commit the
metadata/receipt. A grant record binds the opaque grant id, copyId,
incarnation and generation to the issued slot. It stores no secret or
token-derived digest. Ambiguous crash recovery keeps use closed and
requires a new verified issue if exact provenance cannot be established.
Gitea's existing fixed `SEAT_SCOPES` must not be reused for a read-only role.
Vikunja needs a reviewed verifier for the complete route-group scope set
and bot/project access. Until each adapter exists, that service's copy
binding refuses. Do not claim the existing named-agent path provides this
proof. Extending remote minting beyond its current approved envelope is a
separate protected change, identified in slice CB2 below.
### At launch, restart and every use
The first build uses the existing restart model for **new references**.
Approval may record a binding, but it is not usable until the org bus
restarts and verifies it. Surface `restart-required`, not a successful
launch. Today reference/bot changes already require restart
(`packages/bus/src/runtime.mjs:547-583`). Do not add live reference reload
in the first slice. Revocations take effect immediately and never wait
for this restart.
On boot and every launch, re-resolve the effective role and binding,
validate source/copyId/incarnation/generation, and obtain fresh trusted
service scope/resource proof for the actual slot contents. If verification
is unavailable, launch refuses. All required services must be bound and
available. Bindings may be approved incrementally, but partial success
cannot launch. A stale file alone never counts as a binding.
Preserve A3's organization revision fence. Add a separate launch authority
digest over frozen definition identity, copyId, org incarnation, binding
generation and verified grant generation. Do not redefine the writer's
document revision. Carry both into final admission and `bindLaunch`, and
bind the admitted generations to the session. A pending revocation blocks
admission, including the interval between final admission and spawn/bind.
If a child was spawned before revocation won, deny its capability and stop
it; it must perform no credential call. Do not release a launch fence on an
uncertain outcome until that child is confirmed stopped.
At every credential-bearing operation, check session org/instance, active
incarnation, effective scopes/resources, binding/grant generation and slot
liveness before any external request. Derive the slot from the session,
never from request arguments or thread text. Raw tokens and credential file
paths do not enter sandboxes. The trusted service adapter checks each
method/resource as well as token permissions. Constrain outbound destinations
to the approved service origin and reject redirects carrying authorization
to another origin. Operation completion rechecks the generation before
publishing its result. Unknown or stale state refuses closed.
## 6. Lifecycle and running copies
All transitions below close credential admission first, invalidate affected
session generations, then stop **all** affected ordinary and thread runs.
Serialize close versus new admission so a concurrent launch cannot escape
the stop inventory. Abort cancellable service calls and wait for all held
uses to settle before slot deletion or replacement. A completed external
write cannot be undone; report that limit, never call local revoke a remote
rollback. Existing non-host `Credentials.use` does not provide the host-key
abort behavior, so generalizing the use fence is required, not assumed
(`packages/bus/src/credentials.mjs:556-569,618-653`).
| Event | Durable outcome and effect on a running copy | Next launch/use |
| --- | --- | --- |
| Unbind service | Owner-approved change bumps generation, records unbound tombstone, closes use, stops every run of that copy, revokes/removes its slot. Never retain a reusable token behind an inactive binding. | `credential-unbound`: `credentials.gitea needs an owner-approved binding.` |
| Emergency revoke | Owner verb immediately closes use, bumps generation, stops all copy runs and performs local revoke. A managed remote grant is also revoked through its approved adapter. Keep tombstone; reissue needs a fresh decision and verification. | `credential-revoked`: `credentials.gitea was revoked; the owner must approve a new binding.` |
| Slot removed or altered outside the writer | A slot liveness/identity check at use and launch refuses even if memory still holds the token. Mark binding unavailable, invalidate session use and stop its runs. Never fall back to cached memory, environment or another slot. Replacement is a newly verified issue. | `credential-unavailable`: `credentials.gitea is unavailable; the owner must verify the slot.` |
| Expiry or upstream revocation | Local expiry checks and service authentication failure close the binding, invalidate use and stop runs. Rotation is a new verified slot generation and owner-approved change; no in-place replacement behind active uses. | `credential-expired`: `credentials.vikunja has expired; the owner must approve a replacement.` Upstream revoke uses `credential-revoked`. |
| Role/definition changed | Any role identity/hash/revision mismatch closes use and stops affected copies. Do not inherit old credentials even for equal service names. A narrower role requires new bounded service proof; a wider role requires approval first and fresh binding. T1 blocks edits of an in-use definition until the change-decision path exists. | `credential-role-changed`: `credentials.gitea no longer matches the approved definition; the owner must review the binding.` |
| Copy fired, source removed, excluded or shadowed | Close use, retire copyId, stop every run, revoke slots and delist the copy. Preserve run/trail evidence. A newly shadowing named instance must not load the old copy's file at the identical slot name. Require cleanup before it can use that name. | `copy-unavailable`: `copy is no longer active in this organization.` |
| Copy-name collision | Refuse attaching either binding. If introduced while running, close affected use and stop both claimants. No first-wins fallback. | `copy-refused`: `copy identity is ambiguous; the owner must resolve the definition collision.` |
| Organization archived | Close the org's admission, invalidate its running sessions and stop every org run before archive completes. Revoke all copy service slots and mark bindings unbound. Restore preserves definitions but requires fresh binding decisions. Never affect another org's runs or slots. | `org-archived`: `organization is archived.` After restore, `credential-unbound`. |
| Organization deleted | Close all admission/use and stop every org run, revoke managed remote grants and local slots before the existing quarantine flow. Preserve evidence/tombstones in deletion evidence. A new org with the same id has a new incarnation and no inherited bindings. | `org-unavailable`: `organization is not active.` Recreated org starts unbound. |
| Revoke/stop/cleanup uncertain | Persist a non-usable pending-revocation state and keep the affected copy or org fenced. Do not publish success or restore memory on write failure. Retry with the same operation id. Remote revocation uncertainty remains visible to the owner. | `credential-revoke-failed`: `credentials.gitea revocation is incomplete; the owner must retry cleanup.` |
These archive/delete effects are proposed extensions. Current archive stops
the bus and marks the registry archived; deletion quarantines org runtime,
document/history and org secret trees, while its receipt says remote-side
revocation is out of scope (`packages/organization/src/org-service.mjs:239-267,332,343-347`).
The new build must explicitly test and add copy-run and copy-grant cleanup;
it cannot infer it from a stopped bus.
Refusal codes in this section are proposed public contracts where new.
Use `human-required` with `Only the organization owner can approve credential
binding.` for an agent/member mutation, `credential-not-yours` with
`credentials.gitea must use this copy's own organization slot.` for a wrong
slot, `credential-scope-exceeded` with `credentials.gitea.scopes exceeds the
approved role.` for excess scopes, and `credential-scope-unverified` with
`credentials.gitea permissions could not be verified.` for missing proof.
`credential-binding-changed` says `credentials.gitea changed; read and approve
again.` for stale generation; `organization-changed` retains the existing
document-race code. Validation errors name field positions such as
`copyBindings entry 1 services field`, not untrusted submitted keys.
All new boundaries allow only registered service/field labels and fixed
remediation text. Never interpolate a submitted value, resolved path, raw
service error, URL, bot name, secret, token hash or token fragment into an
error, receipt or trail. New metadata serializers use an allowlist and
paths-only receipts. Scan human text with the existing secret scanner and
fixed field-size limits; arbitrary unknown keys refuse with a fixed field
label. Metadata holds only validated ids, known scope names and bounded
dates/integers. The scope is every new copy-binding surface, consistent
with the brief's #1916/#1920 requirement, without changing unrelated legacy
diagnostics. Neither reviewer nor implementer needs live secrets to test it.
## 7. Two organizations and identity reuse
Example acceptance fixture: org A and org B both materialize root `writer`
from the same creative template. A's binding selects `(A, writer, gitea)`;
B's selects `(B, writer, gitea)`. Each uses a dedicated remote bot/token whose
resources exclude the other org. B cannot select A's slot or reuse A's grant
record. A path restriction alone would not prevent a token with both orgs'
remote grants, so service proof must check those grants too.
Enforce isolation at all of these independent boundaries:
1. Console resolves the target org from the authorized route and fresh
ownership, not a body-supplied org or an instance-wide owner flag.
2. The writer derives its org and slot from that context and binds the
metadata to the registry incarnation. A copied record from A has the
wrong incarnation in B and refuses before opening any slot.
3. Copy source tuple, copyId and definition digest must match B's materialized
copy. A root definition carries no binding to propagate.
4. Credential loader derives the slot, enforces private regular-file rules,
rejects alias/symlink/hardlink substitution for copy slots and verifies
remote grant ownership. Reject nlink other than one for these slots.
5. Every use derives org/instance from the authenticated session and checks
the active binding generation. A thread, room or caller-supplied instance
cannot choose the key. Session and grant records cannot cross buses.
6. Archive/delete touches only that incarnation's resources. A failed cleanup
never makes credentials valid for a later org or copy with the same name.
Registry incarnation is a dependency of accounts design
(`docs/plans/2026-11-10_accounts-design.md:501-514`). The binding build must
use that stable value; if the target baseline lacks it, land an explicitly
approved incarnation slice first. Do not substitute timestamps, a pathname
or an org id alone. The dedicated store layout remains unchanged.
## 8. T1 dependency and implementation slices
T1 is in flight. Its brief is
`/mnt/storage/scratch/mosaic-lead/uidev/org-release/T1-BRIEF.md`. This design
depends on its org-local definitions, `org:` resolver, revision/hash read,
composition validation and in-use definition guard, as specified in
roles-and-rails sections 4 and 5
(`docs/plans/2026-11-10_roles-and-rails-design.md:157-170,186-228`). Do not
patch T1's files in this lane. Project copies use those org definitions;
root copies keep a frozen root identity. Supporting root-local customized
definitions waits for that resolver to exist, rather than inventing another
definition format here. A4 supplies the authority-change decision engine;
this build extends it, rather than shipping a temporary direct bind verb.
Every slice below is a later proposal requiring Jason's approval before
work. Each ends with focused tests plus affected organization, business,
bus, CLI and Console suites. No slice edits `roles/` or `contracts/`.
| Slice and dependencies | Build and acceptance evidence | Protected changes needing approval |
| --- | --- | --- |
| CB0: identity foundation, after T1/accounts | Reuse incarnation or add its approved missing support. Define copyId, source/hash pin and binding generation semantics. Pure resolver tests cover identical root copies across two orgs, project dotted-name collisions, org shadowing, removed/recreated org and source-name reuse. No secret read and no launch enabled. | Org schema; registry schema only if incarnation is absent. No new rights or role files. |
| CB1: metadata and decisions, after CB0/A4 | Add `copyBindings`, guarded organization writer, owner-only credential subtype under `org.hire`, digest/expiry/single-consumption and paths-only receipts. Tests: owner binds/unbinds; member with org.hire/decide, chief, arbiter, subject agent, tester and reader all fail to resolve/write; agent may not call raw writer; only chief agent can propose; stale revision/digest/generation, expired approval, replay, wrong incarnation and generic write injection have no activation. Pending/partial binding never launches. | Organization and decision payload schema; `VERB_RIGHTS`/owner dispatch changes even though no new grantable right. Approval explicitly covers the reuse of org.hire with the owner restriction. |
| CB2: verified service slots, after CB1 | Reuse self-only store and dedicated bot identity; add complete Gitea/Vikunja verification or exact bounded mint adapters. Tests use fake services for narrower/equal/broader scopes, read/write hierarchy, extra route verbs, unrelated repos/projects, reused foreign bot/grant, opaque/unverifiable token, wrong date, symlinks/hardlinks, stale file and token replacement. No request, path or token enters metadata/evidence; lost replies and crash points remain inactive. A live scratch-service acceptance must prove each service/version's permission verifier before that service enables copy launch. | Credential handling/security boundary; grant metadata schema and any remote mint/revoke envelope changes. No automatic reuse of existing fixed Gitea seat scopes. |
| CB3: admission and use, after CB2 | Materialize only verified bound copies; new references visibly need restart. Check document revision plus separate authority digest at prepare, final admission and bindLaunch. Session-bound adapter checks every operation and slot liveness. Race tests mutate role hash, org bindings, token contents or admitted projects during async prepare and between admission/spawn/bind/use. Assert no admitted credential call, including an empty project manifest. Concurrent revoke denies new work; no cached-token fallback after unlink. | Launcher/broker credential enforcement boundary; session/launch authority fields and any store schema needed to persist fences. No live reference-reload policy. |
| CB4: lifecycle and recovery, after CB3 | Test unbind, emergency revoke, slot removal, expiry, role change, fire, collision, shadowing, archive/restore, delete/recreate. Each closes admissions, invalidates ordinary and every thread run, aborts or drains uses, and denies later calls. Inject failures before/after metadata publication, secret staging, remote revoke, stop acknowledgement and receipt write; restart never resurrects authority. Include named-instance shadowing over a former copy's slot. Other org continues. | Credential/revocation and org lifecycle boundary; durable pending-operation schema. Existing deletion quarantine must not silently claim remote revoke. |
| CB5: two-org thread acceptance and operator flow, after CB4 | Finish A3's deferred test with a shipped Gitea-requiring creative thread role in both orgs. Approve independent verified bindings; boot real buses and use fake harness/service, create one thread/session per org, launch both, write/read records, reopen each. Assert distinct session/run/trail/workspace and slot identities, no A record visible in B, no B request uses A's token, and B survives A revoke/archive/delete. Reject raw A binding/grant replay in B. Operator flow shows computed scope lines, confirmation, missing service, restart and remediation states, with sentinel redaction tests on every response/event/log. No screenshot gate for this doc lane. | Console mutation/decision flow; no further right or role changes. Any discovered schema delta returns to approval before build. |
Existing regressions to retain include organization `credentials.test.mjs`,
`role.test.mjs`, `agent-copies.test.mjs`, `instance-credential-transitions.test.mjs`,
bus `credential-store.test.mjs`, `credential-verbs.test.mjs`,
`agent-copies.test.mjs`, and CLI `thread-launch.test.mjs` and
`launcher-final-race-probe.test.mjs`. Add meaningful new focused tests for
the guarantees above; merely checking the proposed JSON shape is inadequate.
Acceptance samples for this document are the seven brief requirements:
storage/writer/locks in section 3; right/rail/decision in section 4;
role ceiling and verification in section 5; lifecycle/code/text in section 6;
two-org enforcement in section 7; tests/slices/protected changes in section 8;
and at most three owner questions below. No visual samples apply.
## 9. Questions for Jason, with recommendations
1. Approve `org.hire` as the entry right while keeping every credential
binding/unbinding approval owner-only? Recommend yes: it matches the
existing hire decision model and does not grant members token authority.
2. Approve the overlay schema and CB0-CB5 security changes, including complete
service scope/resource proof as a prerequisite and restart for new
references? Recommend yes. A service without a verifier stays refused;
implement Gitea first if its verifier is ready before Vikunja's.
3. On archive/restore, discard copy credentials and require fresh bindings?
Recommend yes. It adds setup on restore, but avoids keeping dormant
usable grants and gives archive one clear revocation meaning.
These are recommendations, not approvals. The Lead takes this design to
Jason. No implementation or rollout is authorized by this document.
+203 -88
View File
@@ -9,7 +9,7 @@ import { initializeControl, processingStatus } from '../../processing-control/sr
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
import { loadRequirements } from '../../tasks/src/requirements.mjs';
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs';
import { pluginName, ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs';
// Kept closed while S1 lands; its vocabulary is the integration authority.
export const ACTIONS = Object.freeze([
@@ -43,6 +43,7 @@ export const ACTIONS = Object.freeze([
// Row 194 (#1692): routed under PRD 1.7 REQ-COS-3, below.
'role.hire',
'role.fire',
'role.change',
'credential.mint',
'spend',
'deploy',
@@ -85,6 +86,7 @@ const ROUTED = new Set([
// 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',
'role.change',
// PRD 1.16 REQ-WAIT-3 and REQ-LIB-6 (row 285, #1790): a creative agent's library write.
'library.write',
]);
@@ -172,7 +174,7 @@ export const USER_VERBS = Object.freeze(['project.list', 'thread.create', 'threa
'thread.share', 'thread.unshare', 'thread.shares']);
// Row 252 (#1753, design sections 2-3): the only verbs a Console owner cap reaches. Every other verb
// refuses it human-required before its own checks, and these refuse every other cap.
export const CONSOLE_VERBS = Object.freeze(['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
export const CONSOLE_VERBS = Object.freeze(['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
// Task 3 (#1839): and the owner's intake controls. The reads and writes are the same lifecycle methods the PM's
// unsuffixed verbs reach, with the owner's verified (provider, iss, sub) as the only caller; the PM-only
// request.question and the PM's own reads keep their unsuffixed names and refuse a console cap.
@@ -193,6 +195,15 @@ const userLevel = (verb) => (VIEWER_VERBS.includes(verb) ? 'viewer' : 'collabora
export const BOARD_VERBS = Object.freeze(['inbox', 'agents', 'thread.claims', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'state', 'usage']);
// The person's task-store writes, distinct from the agent's singular task.* actions.
export const PERSON_TASK_VERBS = Object.freeze(['tasks.create', 'tasks.edit', 'tasks.archive', 'tasks.unarchive', 'tasks.delete']);
// #1873 T1: the human's definition writes, each with its [allowed, required] argument keys. Kept here,
// not imported, as the sandbox client loads this file; runtime.mjs maps each verb to the verb module
// (organization/src/definition-verbs.mjs names the same four, and a test holds them equal).
export const DEFINITION_BUS_KEYS = Object.freeze({
'definition.import': [['revision', 'template', 'as'], ['revision', 'template']],
'definition.invent': [['revision', 'name', 'definition'], ['revision', 'name', 'definition']],
'definition.edit': [['revision', 'rev', 'name', 'definition'], ['revision', 'rev', 'name', 'definition']],
'definition.remove': [['revision', 'rev', 'name'], ['revision', 'rev', 'name']],
});
// Row 175: the organization verbs a tester may call. Its writes run every check the human's do
// and log actor `tester`; row 176 adds routes reads; limits.set, organization.set and organization.revision refuse it.
export const TESTER_ORGANIZATION_VERBS = Object.freeze(['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']);
@@ -217,14 +228,6 @@ const LIFECYCLE_CODE = /^[a-z-]{1,64}$/;
// Row 194 S6 (D2.2): the stop outcomes a fire's lifecycle.done reports, each written as its own event.
const FIRE_STOPS = ['killed', 'alive'];
const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
// Row 194 (#1692, D2.1, D2.2, PRD 1.7 REQ-COS-3): the chief's hire and fire arguments, allowed then
// required. Each carries the reason the log records.
const HIRE_FIRE_ARGS = Object.freeze({
'role.hire': [['name', 'definition', 'vars', 'reason'], ['name', 'definition', 'reason']],
'role.fire': [['name', 'reason'], ['name', 'reason']],
});
// N3: hire's vars hold only the model key and limits.* keys. The writer checks the values.
const HIRE_VAR = (key) => key === 'model' || key.startsWith('limits.');
// What instance.request and the host ops answer about a request.
const lifecycleView = (r) => ({
request: r.request, action: r.action, instance: r.instance, by: r.by, at: r.at, state: r.state,
@@ -533,7 +536,7 @@ function checkBusiness(name, b) {
if (!object(a) || !Array.isArray(a.withinRole) || !Array.isArray(a.crossRole))
fail('invalid-authority');
const all = [...a.withinRole, ...a.crossRole];
if (new Set(all).size !== all.length || all.some((v) => !ACTIONS.includes(v) || GATED.has(v)))
if (new Set(all).size !== all.length || all.some((v) => (!ACTIONS.includes(v) && !pluginName(v)) || GATED.has(v)))
fail('invalid-authority');
}
if (!Object.hasOwn(b.roles, b.arbiters.technical) || !Object.hasOwn(b.roles, b.arbiters.delivery))
@@ -593,6 +596,8 @@ export class Broker {
#builtin;
#builtinBinding;
#organization;
#hires;
#pluginService;
#credentialVerbs;
#proxyInit;
#proxyPage;
@@ -655,7 +660,7 @@ export class Broker {
#processing;
#taskTracker;
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null, workspaceRead = null, taskTracker = null }) {
constructor({ store, businesses, pluginService = null, secretCheck = () => {}, credentialStatus = () => [], organization = null, hires = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null, workspaceRead = null, taskTracker = null }) {
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
// Row 194 b15 (sage-2501): the function-argument checks come before anything reads the store.
if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function');
@@ -678,6 +683,8 @@ export class Broker {
this.#secretCheck = secretCheck;
this.#credentialStatus = credentialStatus;
this.#organization = organization;
this.#hires = hires;
this.#pluginService = pluginService;
// Row 199: the runtime's credential lifecycle (credential-verbs.mjs); it holds the store, not the broker.
this.#credentialVerbs = credentialVerbs;
this.#proxyInit = proxyInit;
@@ -1550,6 +1557,11 @@ export class Broker {
// 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 (b.roles[instance]?.unbound?.length) throw new BusError('credential-unbound', `${instance} has no binding for ${b.roles[instance].unbound.join(', ')}`);
if(copy?.level==='root') {
const refusal=this.#hires?.rootRefusal ? this.#hires.rootRefusal(copy.name) : this.#hires?.rootActive && !this.#hires.rootActive(copy.name) ? 'instance-fired' : null;
if(refusal)fail(refusal);
}
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}`);
@@ -1837,6 +1849,7 @@ export class Broker {
if (s.tester) fail('tester-refused');
if (s.reader) fail('read-only');
if (s.human) fail('agent-required');
this.#copyRefused(this.#business(s.business),s.role);
const h = this.#own(s);
if (h?.op !== 'claim' || (s.thread ? h.generation !== this.#runGeneration(s) : h.holder_run !== s.run))
fail(this.#launcherReleased.has(s.business + ':' + s.run) ? 'launcher-released'
@@ -2151,6 +2164,13 @@ export class Broker {
}
bindLaunch(record) {
const { key, session, old, body } = this.#launchProof(record);
const launchRole=this.#business(record.business).roles[record.role];
const pluginCapability=[...(launchRole.tools ?? []),...(launchRole.authority?.withinRole ?? []),...(launchRole.authority?.crossRole ?? []),...Object.keys(launchRole.credentials ?? {})].find(pluginName);
if(pluginCapability) {
if(!this.#pluginService)throw new BusError('plugin-disabled',pluginName(pluginCapability));
try { this.#pluginService.check(record.business,record.role); }
catch(e) { throw new BusError(e.code === 'plugin-disabled' ? e.code : 'invalid-request', e.code === 'plugin-disabled' ? e.plugin : undefined); }
}
if (!old) this.#processingWork();
if (!old && !this.#harnessOn(record.business, record.harness)) fail('invalid-harness');
if (!old && record.thread !== undefined && this.#archived(record.business, this.#threadRow(session, record.thread).project)) fail('project-archived');
@@ -2265,6 +2285,14 @@ export class Broker {
run: null,
});
}
// Trusted account transport supplies freshly resolved membership, never request arguments.
// This capability reaches only the A4 verbs; it cannot pass existing owner Console guards.
bindHireConsole({business, actor, subject, rights, owner = false, instanceOwner = false}) {
this.#business(business);
identifier(actor);
if (!object(subject) || !['provider','iss','sub'].every(k=>typeof subject[k]==='string' && subject[k]) || !Array.isArray(rights)) fail('human-required');
return this.#cap({business,human:actor,role:null,run:null,via:'console',console:Object.freeze({...subject}),hireMembership:Object.freeze({actor,rights:[...rights],agent:false,owner,instanceOwner,subject:{...subject}})});
}
bindReader({ business }) {
this.#business(business);
return this.#cap({ business, reader: true, role: null, run: null, human: false });
@@ -2422,6 +2450,7 @@ export class Broker {
options.some(o => !object(o) || !id(o.key) || !text(o.text, 1024)) ||
new Set(options.map(o => o.key)).size !== options.length) return null;
const digest = sha256(JSON.stringify([{ ...d, options }, raise]));
if (['role.hire','role.fire','role.change'].includes(d.action)) return raise.hire ? {kind:'hire',options,...raise.hire} : null;
if (d.action !== 'credential.mint') return 'grant' in raise ? null : { kind: 'decision', options, digest };
if (!object(raise.grant) || typeof raise.grant.method !== 'string' || options.length !== 2) return null;
const approve = options.find((o) => o.key === raise.approvalChoice);
@@ -3337,7 +3366,8 @@ export class Broker {
const result = await write('instance.fire');
step = 'C';
this.#store.transaction(() => {
this.#recordAuthority(sc, 'role.fire', authority, { reason: args.reason, transaction: result.transaction ?? null });
if(sc.human)this.#event(sc,'action.allowed',{action:'role.fire',target:args.name,reason:args.reason,transaction:result.transaction??null},args.name);
else this.#recordAuthority(sc, 'role.fire', authority, { reason: args.reason, transaction: result.transaction ?? null });
this.#event(sc, 'action.allowed', { action: 'fire.done', transaction: result.transaction ?? null, job, grants: out.grants, grantsLeft: out.grantsLeft }, name);
});
} catch (e) {
@@ -4668,7 +4698,14 @@ export class Broker {
fail('role-end-refused');
}
#classification(s, action, target = null) {
if (!ACTIONS.includes(action)) fail('unknown-action');
if (!ACTIONS.includes(action)) {
const name=pluginName(action);
const held=this.#business(s.business).roles[s.role]?.authority;
if(!name || ![...(held?.withinRole ?? []),...(held?.crossRole ?? [])].includes(action))fail('unknown-action');
if(!this.#pluginService)throw new BusError('plugin-disabled',name);
let checked;try {checked=this.#pluginService.check(s.business,s.role);}catch{throw new BusError('plugin-disabled',name);}
if(!checked.view.registry.actions.includes(action))throw new BusError('plugin-disabled',name);
}
if (
this.#hardWait(s, action, target) ||
(action === 'role.launch' && this.#business(s.business).launch?.by !== s.role)
@@ -5915,6 +5952,15 @@ export class Broker {
}).catch(error => { throw this.#refused(s, new BusError(/^proxy-[a-z-]+$/.test(error.code ?? '') ? error.code : 'proxy-init-failed'), request); });
}
this.#secretCheck(args);
if (request.verb === 'plugin.tool') {
if(s.human || s.reader || s.tester || s.user || s.console)fail('agent-required');
keys(args,['tool','arguments'],['tool','arguments']);
this.#store.transaction(()=>this.#agent(s));
if(!this.#pluginService)throw new BusError('plugin-disabled',pluginName(args.tool) ?? undefined);
return this.#pluginService.call(s.business,s.role,args.tool,args.arguments).then(result=>{
this.#secretCheck(result);return result;
}).catch(e=>{throw this.#refused(s,new BusError(['plugin-disabled','invalid-request','not-authorized'].includes(e.code)?e.code:'adapter-failed',e.code==='plugin-disabled'?e.plugin:undefined),{verb:'plugin.tool'});});
}
if (request.verb === 'processing.halt' || request.verb === 'processing.resume')
return this.#requestProcessing(s, request.verb, structuredClone(args));
if (request.verb === 'processing.status') {
@@ -6050,14 +6096,25 @@ export class Broker {
return this.#withLast(cap, s, request);
}
if (CREDENTIAL_VERBS.includes(request.verb)) return this.#requestCredential(s, request.verb, structuredClone(args));
if (request.verb === 'instance.hire' || request.verb === 'instance.fire')
{
const result = this.#chiefLifecycle(s, request.verb, structuredClone(args));
return result?.then ? result.catch(error => { throw this.#refused(s, error, request); }) : result;
if (s.hireMembership && !['hire.preview.console','hire.resolve.console','hire.start.console','hire.templates.console'].includes(request.verb)) fail('human-required');
if(request.verb==='hire.templates.console') {
keys(args,[]);const caller=this.#hireCaller(s);
if(!caller.rights.includes('org.hire'))fail('right-required');
if(!this.#hires?.templates)fail('not-configured');
return this.#hires.templates();
}
if (request.verb === 'instance.hire' || request.verb === 'instance.fire') fail('decision-required');
if (request.verb === 'hire.raise' || request.verb === 'hire.start.console' ||
(request.verb === 'decision.raise' && ['role.hire','role.fire','role.change'].includes(args.action))) {
const input=request.verb==='decision.raise' ? (()=>{ keys(args,['action','hire'],['action','hire']); if(args.action!==`role.${args.hire?.op}`)fail('invalid-request'); return args.hire; })() : args;
return this.#raiseHire(s,input,request.verb==='hire.start.console').catch(e=>{throw this.#refused(s,new BusError(e.code??'invalid-request'),request);});
}
if(request.verb==='decision.preview.console' && ['role.hire','role.fire','role.change'].includes(this.#decision(s,args.id).action))return this.#hireConsole(s,'hire.preview.console',args).catch(e=>{throw this.#refused(s,new BusError(e.code??'outcome-unknown'),request);});
if (['hire.preview.console','hire.resolve.console'].includes(request.verb))
return this.#hireConsole(s,request.verb,args).catch(e=>{throw this.#refused(s,new BusError(e.code??'outcome-unknown'),request);});
if (request.verb === 'grants.scope' || request.verb === 'grants.scope.set')
return this.#grantScopeVerb(s, request.verb, structuredClone(args));
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set", "library.limits.set"].includes(request.verb)) {
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set", "library.limits.set"].includes(request.verb) || Object.hasOwn(DEFINITION_BUS_KEYS, request.verb)) {
// Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose
// file lists organization.change. It takes the same keys, runs the same write path and
// logs the same receipt; only the actor differs: the role definition and instance.
@@ -6112,6 +6169,13 @@ export class Broker {
else if (request.verb === "library.limits.set") {
keys(args, ["revision", "replyLimits"], ["revision", "replyLimits"]);
this.#human(s);
}
// #1873 T1: the definition writes need org.hire. Until row 193, as #mayHuman, the business's
// one human (the owner) passes; the tester is refused above and an agent here.
else if (Object.hasOwn(DEFINITION_BUS_KEYS, request.verb)) {
keys(args, ...DEFINITION_BUS_KEYS[request.verb]);
this.#human(s);
if (s.human !== this.#business(s.business).human) fail("human-required");
} else {
keys(args, ORGANIZATION_KEYS, ["revision"]);
// Row 194 (D2.4, B7): the chief can't change a kept instance's model, as that replaces it at
@@ -6247,47 +6311,121 @@ export class Broker {
}
return result;
}
// Row 194 (#1692, D2.1, D2.2): the chief's hire and fire. PRD 1.7 REQ-COS-3 routes both within the
// chief's role with a reason, as role.end ends a run (S2e). The writer commits outside the store
// transaction, so the grant is checked before it and the use logged only after it, as
// organization.set's grant is.
#chiefLifecycle(s, verb, a) {
const hire = verb === 'instance.hire', action = hire ? 'role.hire' : 'role.fire';
this.#agent(s);
const [allowed, required] = HIRE_FIRE_ARGS[action];
keys(a, allowed, required);
this.#hireFireArgs(action, a);
const args = a;
const b = this.#business(s.business);
if (b.launch?.by !== s.role) fail('role-not-chief');
if (!this.#organization) fail('not-configured');
// The grant: the action is routed and on the chief's withinRole. Without both it needs a decision
// (decision-required), and these verbs take none.
const authority = { target: args.name };
this.#checkAuthority(s, action, authority);
// D2.4: a kept name is neither hired nor fired.
if ((b.launch?.keep ?? []).includes(args.name)) fail('instance-kept');
const actor = b.roles[s.role].definition + '.' + s.role;
const context = { instance: s.role, run: s.run, reason: args.reason };
// The writer gets its own keys. The reason goes in the context, which its receipt logs.
const { reason: _, ...written } = args;
const write = (op) => {
try {
const result = this.#organization(s.business, op, written, actor, context);
return result?.then ? result.catch(error => this.#organizationFail(error, verb)) : result;
} catch (e) {
this.#organizationFail(e, verb);
}
#hireCaller(s) {
if (!s.console || !s.human || s.reader || s.tester) fail('human-required');
if(s.hireMembership) return s.hireMembership;
if(s.human!==this.#business(s.business).human)fail('human-required');
return {actor:s.human,agent:false,rights:['org.hire'],owner:true,instanceOwner:true,subject:s.console};
}
async #raiseHire(s, input, immediate) {
const b=this.#business(s.business);
// Change is a replacement composition and uses the existing role.hire grant.
const chiefAllowed=()=>{
this.#agent(s);
if(s.role!==this.#business(s.business).launch?.by)fail('role-not-chief');
if(!['hire','change','fire'].includes(input?.op))fail('invalid-request');
if(this.#classification(s,input.op==='fire'?'role.fire':'role.hire')!=='within-role')fail('not-authorized');
};
if (hire) {
this.#hireable(s, args.definition);
const result = write('instance.hire');
const finish = result => {
this.#store.transaction(() => this.#consumeAuthority(s, action, authority, { reason: args.reason, transaction: result.transaction }));
return result;
};
return result?.then ? result.then(finish) : finish(result);
if(immediate) { const c=this.#hireCaller(s); if(!c.rights.includes('org.hire'))fail('right-required'); }
else chiefAllowed();
if(!this.#hires)fail('not-configured');
const did=randomUUID(), at=this.#now(), expiresAt=new Date(Date.parse(at)+7*86400000).toISOString();
const p=await this.#hires.prepare(s.business,structuredClone(input),{id:did,expiresAt});
// Recheck the chief after asynchronous preparation; a released run cannot raise.
if(!immediate)chiefAllowed();
else {
const caller=this.#hireCaller(s);
if(((p.flagged.length || p.credentialLines.length) && !caller.owner) || (p.level==='root' && !caller.instanceOwner))fail('hire-owner-required');
}
const action=`role.${p.op}`;
this.#store.transaction(()=>{
const raiser=immediate?'human':s.role;
this.#store.run('INSERT INTO decisions(id,at,business,project,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)',
did,at,s.business,p.project??null,raiser,immediate?'human-console':s.run,'gated',action,'human',`${p.op} ${p.name} at ${p.level} level?`,JSON.stringify([{key:'yes',text:'Approve'},{key:'no',text:'Deny'}]),'no',0);
this.#event(s,'action.allowed',{operation:'decision.raise',decision:did,action,approvalChoice:'yes',target:p.name,hire:p},did);
});
if(immediate) return this.#hireConsole(s,'hire.resolve.console',{id:did,choice:'yes',digest:p.digest});
return {...this.#decisionView(this.#decision(s,did)),hire:p};
}
async #hireConsole(s,verb,a) {
const caller={...this.#hireCaller(s),now:()=>Math.max(Date.now(),this.#clock)};
keys(a,verb==='hire.preview.console'?['id','adjust']:['id','choice','digest','adjust'],verb==='hire.preview.console'?['id']:['id','choice','digest']);
identifier(a.id);
const d=this.#decision(s,a.id), raise=this.#raised(s.business,d.id);
let p=raise?.hire;
if(!['role.hire','role.fire','role.change'].includes(d.action) || !p)fail('decision-not-eligible');
const closed=this.#closed(d.id);
if(closed)fail(closed.by==='expiry'?'decision-expired':'decision-closed');
if(this.#expireHire(s,d,p))fail('decision-expired');
if(!caller.rights.includes('org.hire'))fail('right-required');
if(a.adjust!==undefined) {
if(!this.#hires?.preview)fail('not-configured');
p=await this.#hires.preview(s.business,d.id,{...caller,adjust:a.adjust});
}
if((p.flagged.length || p.credentialLines.length) && !caller.owner || p.level==='root' && !caller.instanceOwner)fail('hire-owner-required');
if(verb==='hire.preview.console')return {id:d.id,business:s.business,question:d.question,kind:'hire',options:JSON.parse(d.options),...p};
if(a.digest!==p.digest)fail('decision-stale');
if(a.choice==='yes') { if(!this.#hires)fail('not-configured'); await this.#hires.check(s.business,d.id,{...caller,digest:p.digest,adjust:a.adjust}); }
if(!['yes','no'].includes(a.choice))fail('invalid-choice');
// A durable close/consume precedes the external file effect. A crash never replays an approval.
this.#store.transaction(()=>{
if(this.#closed(d.id))fail('decision-closed');
this.#store.run('INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)',d.id,this.#now(),'resolved',caller.actor,a.choice,'console');
this.#event(s,'human.input',{kind:'answer',decision:d.id,op:'resolved',choice:a.choice,via:'console',subject:s.console},d.id);
if(a.choice==='yes')this.#event(s,'action.allowed',{operation:'hire.consume',decision:d.id,digest:p.digest,hire:p},d.id);
});
if(a.choice==='no')return {id:d.id,op:'resolved',choice:a.choice};
if(!this.#hires)fail('not-configured');
try {
if(p.op==='fire' && p.level!=='org')await this.#stopCopyApproved(s,p);
const result=p.op==='fire' && p.level==='org' ? await this.#fireApproved(s,p) : await this.#hires.apply(s.business,d.id,{...caller,digest:p.digest,adjust:a.adjust});
if(p.op==='fire' && p.level!=='org')this.#store.transaction(()=>this.#event(s,'action.allowed',{action:'fire.done',decision:d.id},p.level==='project'?`${p.name}.${p.project}`:p.name));
this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:result.done===false?'hire.started':'hire.apply',decision:d.id,transaction:result.transaction??null},d.id));
return {...result,id:d.id,op:'resolved',choice:a.choice,transaction:result.transaction??null};
} catch(e) {
this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:'hire.failed',decision:d.id,code:e.code??'outcome-unknown'},d.id));
throw e;
}
}
#expireHire(s,d,p,insideTransaction=false) {
if(!p || Date.parse(p.expiresAt)>Math.max(Date.now(),this.#clock) || !Number.isFinite(Date.parse(p.expiresAt)))return false;
const close=()=>{
if(this.#closed(d.id))return;
this.#store.run('INSERT INTO decision_events(decision,at,op,by,via) VALUES(?,?,?,?,?)',d.id,this.#now(),'withdrawn','expiry','broker');
this.#event({business:s.business,role:null,run:null},'action.allowed',{operation:'hire.expired',decision:d.id},d.id);
};
if(insideTransaction)close();else this.#store.transaction(close);
return true;
}
#expireHires(s) {
for(const d of this.#store.all(`SELECT d.* FROM decisions d WHERE business=? AND action IN ('role.hire','role.fire','role.change') AND NOT EXISTS(SELECT 1 FROM decision_events x WHERE x.decision=d.id AND x.op IN ${CLOSED})`,s.business))
this.#expireHire(s,d,this.#raised(s.business,d.id)?.hire,true);
}
async #stopCopyApproved(s,p) {
const instance=p.level==='project'?`${p.name}.${p.project}`:p.name;
const b=this.#business(s.business);
if(!b.roles[instance])return;
if(this.#revoking.has(s.business+'/'+instance))fail('fire-in-progress');
const set=this.#store.transaction(()=>{
const held=this.#threaded(s.business,instance)?this.#threadHolders(s.business,instance):[this.#holder(s.business,instance)].filter(h=>h?.op==='claim');
for(const h of held)this.#claimEnd(s,h,'revoke',p.id);
const runs=[...this.#runs.values()].filter(r=>r.business===s.business&&r.role===instance).map(r=>r.run);
const request=runs.length?randomUUID():null;
if(request)this.#event(s,'action.allowed',{action:'lifecycle.end',request,via:'human',by:s.human,runs,reason:'Approved fire decision',kill:true},instance);
this.#event(s,'action.allowed',{action:'fire.stopped',request,decision:p.id},instance);
return this.#grantSet(s.business,instance);
});
if(set.length)await this.#stepG(s.business,instance,set,{job:p.id,reason:'Approved fire decision',by:s.human,flag:{fire:true}});
}
// The existing row-281 fire executor, now entered only after a consumed human decision.
#fireApproved(s,p) {
const args={name:p.name,reason:'Approved fire decision'},verb='instance.fire',action='role.fire';
const b=this.#business(s.business),authority={target:p.name};
const write=op=>{
try {const result=this.#organization(s.business,op,{name:p.name,revision:p.revision},s.human,{decision:p.id,digest:p.digest});
return result?.then?result.catch(e=>this.#organizationFail(e,verb)):result;
} catch(e) {this.#organizationFail(e,verb);}
};
if (!Object.hasOwn(b.roles, args.name)) fail('unknown-role');
if (END_PROTECTED.includes(b.roles[args.name].definition)) fail('organization-definition-refused');
if (b.launch?.by === args.name) fail('organization-instance-referenced');
@@ -6329,7 +6467,7 @@ export class Broker {
// A non-empty set: step G, B and C run as a job, and the verb answers now with no transaction.
if (stopped.job) {
const { job, set, ...rest } = stopped;
const sc = { business: s.business, role: s.role, run: s.run, thread: s.thread, human: null };
const sc = { business: s.business, role: s.role, run: s.run, thread: s.thread, human: s.human };
this.#startGrantJob(s.business, args.name, verb, () => this.#fireJob(sc, args, authority, write, job, set));
return { ...rest, job, revoking: set.map((g) => g.label), done: false };
}
@@ -6338,41 +6476,13 @@ export class Broker {
// Step C: log the fire and record it done. A failure at B or C leaves I firing until a repeat.
const finish = result => {
this.#store.transaction(() => {
this.#consumeAuthority(s, action, authority, { reason: args.reason, transaction: result.transaction ?? null });
this.#event(s,'action.allowed',{action:'role.fire',operation:'hire.apply',decision:p.id,transaction:result.transaction??null},args.name);
this.#event(s, 'action.allowed', { action: 'fire.done', transaction: result.transaction ?? null }, args.name);
});
return { ...result, ...stopped };
};
return result?.then ? result.then(finish) : finish(result);
}
// The shape of hire and fire arguments.
#hireFireArgs(action, args) {
identifier(args.name);
// FED R3 r4 (sage-2557): `user` is the granted user's principal, as checkBusiness refuses it.
if (args.name === 'human' || args.name === 'tester' || args.name === 'user') fail('invalid-request');
string(args.reason, 500);
if (action === 'role.fire') return;
identifier(args.definition);
if ('vars' in args && (!object(args.vars) || !Object.keys(args.vars).every(HIRE_VAR))) fail('invalid-request');
}
// D2.3 at hire, against the role files as they stand: a version 2 definition whose every action is
// on HIRE_ALLOWED (W-N2), named in the chief role file's hireable list and not in launch.noHire.
#hireable(s, definition) {
if (!this.#roleFile) fail('not-configured');
const b = this.#business(s.business);
const load = (name) => {
try {
return this.#roleFile(name);
} catch (e) {
if (e.name === 'BusinessError') fail('definition-not-hireable');
throw e;
}
};
const def = load(definition);
if (def.roleVersion !== 2 || unhireableAction(def) !== null) fail('definition-not-hireable');
if (!(load(b.roles[s.role].definition).hireable ?? []).includes(definition)) fail('definition-not-hireable');
if ((b.launch?.noHire ?? []).includes(definition)) fail('definition-not-hireable');
}
// The bus code for an organization writer's refusal. Anything the writer didn't refuse by name is
// outcome-unknown: the write may have committed.
#organizationFail(e, verb) {
@@ -6388,6 +6498,8 @@ export class Broker {
if ((verb === "instance.hire" || verb === "instance.fire") && e.code === "definition-not-hireable") fail(e.code);
// Row 194 (B7, webby-0094 N3): the limits writer's keep check, against the file, as well.
if (e.code === "instance-kept") fail(e.code);
// #1873 T1: the definition writer's refusals pass by code; a composition refusal stays invalid-request.
if (Object.hasOwn(DEFINITION_BUS_KEYS, verb) && /^definition-[a-z-]+$/.test(e.code ?? "")) fail(e.code);
// Row 194 (brief item 8): a fire's or a delete's revoke failed, and nothing was written.
if ((verb === "instance.fire" || verb === "instances.set") && e.code === "credential-revoke-failed") fail(e.code);
// Row 65: the route checks every write of an instance's route vars runs.
@@ -7271,6 +7383,7 @@ export class Broker {
identifier(a.id);
if (a.note !== undefined) string(a.note);
const d = this.#decision(s, a.id);
if (['role.hire','role.fire','role.change'].includes(d.action) && ['decision.resolve','decision.resolve.console'].includes(verb)) fail('hire-resolution-required');
if (this.#closed(d.id)) fail('decision-closed');
// Recheck eligibility, context digest and membership in the current options in this
// transaction, before any resolution, audit, notification or grant execution.
@@ -7730,6 +7843,7 @@ export class Broker {
const allowed = verb === 'trail' ? ['subject'] : verb === 'tasks' ? TASK_ARGS : [];
keys(a, allowed, verb === 'trail' ? ['subject'] : []);
if (!s.human && !s.reader) this.#agent(s);
if (verb === 'inbox')this.#expireHires(s);
if (verb === 'inbox')
return this.#store
.all(
@@ -7775,6 +7889,7 @@ export class Broker {
case 'decisions': {
if (!s.human && !s.reader) fail('human-required');
keys(a, ['list'], ['list']);
this.#expireHires(s);
if (a.list === 'roles')
return this.#store
.all(
+2 -2
View File
@@ -67,7 +67,7 @@ class Failure extends Error {
// lineages, each the current, retiring and earlier ids of one agent. A daily key quota follows
// the lineage that holds its id, so it outlives rotation and re-issue. Both run only while a
// daily key quota is set and a check is switched on. Without keyLineages they refuse.
export function createLimits({ store, read, log, makeLoader = createLoader, keyForSubject = () => null,
export function createLimits({ store, read, pluginDataRoot = null, log, makeLoader = createLoader, keyForSubject = () => null,
keyLineages = () => { throw new Failure('proxy-key-refused'); } }) {
if (typeof read !== 'function' || typeof log !== 'function') throw new TypeError('createLimits needs read and log');
const loaders = new Map();
@@ -103,7 +103,7 @@ export function createLimits({ store, read, log, makeLoader = createLoader, keyF
return validLineages(value) ? value : null;
}
const loaderFor = business => {
if (!loaders.has(business)) loaders.set(business, makeLoader({ log }));
if (!loaders.has(business)) loaders.set(business, makeLoader({ log, dataRoot:pluginDataRoot }));
return loaders.get(business);
};
+2 -2
View File
@@ -4,7 +4,7 @@ import { createLoader } from '../../plugins/src/loader.mjs';
const validId = s => typeof s === 'string' && /^[a-z0-9][a-z0-9._-]{0,63}$/.test(s);
const validModel = s => typeof s === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/.test(s);
const disabled = () => ({ status: 'disabled', observedAt: null, models: [], notices: [] });
export function createModelCatalogue({ client, settings, now = Date.now, makeLoader = createLoader, proxyLock = null }) {
export function createModelCatalogue({ client, settings, pluginDataRoot = null, now = Date.now, makeLoader = createLoader, proxyLock = null }) {
const cache = new Map(), flights = new Map(); let generation = 0, writing = 0;
function invalidate() { generation++; cache.clear(); }
function begin() { writing++; invalidate(); }
@@ -19,7 +19,7 @@ export function createModelCatalogue({ client, settings, now = Date.now, makeLoa
try {
if (off(id)) return disabled();
const names = [...settings(id)['model-provider']];
const loader = makeLoader({ log: () => {} });
const loader = makeLoader({ log: () => {}, dataRoot:pluginDataRoot });
const result = await loader.load({ 'model-provider': names });
if (result.refused.length || loader.snapshot('model-provider').length !== names.length) throw Error();
const c = client(); if (!c) throw Error();
+12 -13
View File
@@ -198,12 +198,12 @@ export class ModelKeys {
// Host-only callbacks are the existing synchronous organization path.
hire(instance, check, publish) {
return this.#exclusive(async () => {
check(); // Fresh validation and captured revision AFTER controller queue.
await check(); // Fresh validation and captured revision AFTER controller queue.
const existed = this.hasRecord(instance);
try {
await this.#issue(instance);
check(); // Recheck captured revision AFTER HTTP, before publication.
return publish();
await check(); // Recheck captured revision AFTER HTTP, before publication.
return await publish();
} catch (error) {
// A newly issued generation has not escaped to queued admission yet.
if (!existed && this.hasRecord(instance)) {
@@ -347,7 +347,7 @@ export function modelOrganization({ modelKeys, writer }) {
};
const wrap = invoke => (business, verb, args, actor, context) => {
const isRevoke = verb === 'instance.fire' || (verb === 'instances.set' && args.op === 'delete');
if (!isRevoke && verb !== 'instance.hire') return invoke(business, verb, args, actor, context);
if (!isRevoke) return invoke(business, verb, args, actor, context);
return (async () => {
if (pending.has(business)) fail('organization-busy');
const agent = args.name ?? args.instance, instance = business + '/' + agent;
@@ -355,19 +355,11 @@ export function modelOrganization({ modelKeys, writer }) {
if (verb === 'instances.set') invoke(business, 'instances.check', { instance: agent }, actor, context);
const { revision } = writer.read(business);
if (args.revision !== undefined && args.revision !== revision) fail('revision-conflict');
const checkHire = () => {
if (writer.read(business).revision !== revision) fail('revision-conflict');
const checked = invoke(business, 'instance.hireCheck', args, actor, context);
if (checked.revision !== revision) fail('revision-conflict');
return checked;
};
if (!isRevoke) checkHire(); // No registration or proxy I/O before shared checks.
const prepare = { instance, revision, remoteRevoked: false };
pending.set(business, prepare);
const existed = modelKeys.hasRecord(instance);
try {
if (isRevoke) { await modelKeys.prepareRevoke(instance); prepare.remoteRevoked = existed; }
else return await modelKeys.hire(instance, checkHire, () => invoke(business, verb, args, actor, context));
return invoke(business, verb, args, actor, context);
} catch (error) {
if (isRevoke && prepare.remoteRevoked && (error.code === 'revision-conflict' || error.code === 'revision-conflict-revoked')) modelKeys.revisionConflict(instance);
@@ -375,5 +367,12 @@ export function modelOrganization({ modelKeys, writer }) {
} finally { pending.delete(business); }
})();
};
return { writer: adapter, revoke, wrap };
const hire = async (business, agent, check, publish) => {
if (pending.has(business)) fail('organization-busy');
const prepare = { instance: `${business}/${agent}`, revision: writer.read(business).revision, remoteRevoked: false };
pending.set(business, prepare);
try { return await modelKeys.hire(prepare.instance, check, publish); }
finally { pending.delete(business); }
};
return { writer: adapter, revoke, wrap, hire };
}
@@ -19,6 +19,7 @@ export function busOrganization(business, resolved) {
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].tools = [...(r.limits.tools ?? [])];
out.roles[role].web = (r.limits.tools ?? []).includes('web');
// Row 210 (#1707, design D5): resolveInstance leaves host.<id> keys out of the session's
// credentials. The bus child uses them, so they come back here from the business entry.
+2 -2
View File
@@ -8,8 +8,8 @@ const KINDS = ['input', 'output', 'cacheWrite', 'cacheRead'];
const ID = /^[a-f0-9]{12}$/;
const plain = n => n <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(n) : String(n);
// makeLoader is the plug-in loader's factory; a test wraps it to see what a module is handed.
export function createProxyUsage({ store, read, client, records, log, now = Date.now, makeLoader = createLoader, enableDrain = false }) {
const loader = makeLoader({ log });
export function createProxyUsage({ store, read, pluginDataRoot = null, client, records, log, now = Date.now, makeLoader = createLoader, enableDrain = false }) {
const loader = makeLoader({ log, dataRoot:pluginDataRoot });
let due = 0, previous = '', pending = null, closed = false;
const states = new Map();
// #778: per enabled business, the batches taken off the proxy queue and then not written, since
+7
View File
@@ -1,6 +1,7 @@
// 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(' ')) {
@@ -8,6 +9,12 @@ const group = (codes, rail, nextStep) => {
entries.push([code, guidance]);
}
};
group("definition-shared hire-resolution-required hire-owner-required decision-expired right-required", "R5/R8/R9: digest-bound hire approval by a permitted person", "Use the hire Console path with org.hire. Flagged, credential and root decisions need their owner; expired decisions need a fresh proposal.");
group("root-fire-shared", "R3/R8: root fire must revoke every copy's grants", "Other organizations may retain running copies and grants. Ask the owner to coordinate revocation across those organizations before removing the root agent.");
group("copy-stale", "R3: root policy changed since this bus booted", "Ask the owner to restart the organization bus to load the approved root policy, then launch a fresh copy.");
group("root-policy-unreadable", "R3: root policy evidence must be readable", "Ask the owner to inspect root agent receipt metadata and restart the bus after repairing the read failure.");
group("plugin-disabled", "R3/R6/R10: installed, pinned and enabled plugin capabilities", "Ask the instance owner to verify the installed plugin and the organization owner to enable it. A replacement of an already loaded package needs a host restart; then relaunch the agent.");
group("plugin-restart-required", "R3/R10: one imported package pin per host process", "Ask the instance owner to restart the host after replacing an already loaded package, then retry through the supported path.");
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",
+60 -28
View File
@@ -1,3 +1,7 @@
import { hireDecisions } from '../../organization/src/hire-decisions.mjs';
import { organizationPlugins } from '../../plugins/src/organization.mjs';
import { pluginService } from '../../plugins/src/tools.mjs';
import { readPluginLock } from '../../plugins/src/pinned.mjs';
import { createRequirementsHost } from '../../tasks/src/builtin.mjs';
import { createRequirementCatalog } from './requirement-catalog.mjs';
import { readPrdMaterial } from '../../project/src/prd.mjs';
@@ -33,6 +37,8 @@ import { createProxyLock } from './proxy-lock.mjs';
import { ModelKeys, modelOrganization, stopModelRuns, MODEL_STOP_TIMEOUT_MS } from './model-keys.mjs';
import { instanceSettings } from '../../organization/src/instance-settings.mjs';
import { organizationWriter, OrganizationError, setReplyLimits } from '../../organization/src/organization.mjs';
import { definitionVerbs, DEFINITION_BUS_VERBS } from '../../organization/src/definition-verbs.mjs';
import { ROLE_RIGHTS } from '../../organization/src/org-rights.mjs';
import { getProjectRemote } from '../../organization/src/project-remotes.mjs';
import { credentialVerbs } from './credential-verbs.mjs';
import { loadWithCopies } from '../../organization/src/agent-copies.mjs';
@@ -57,7 +63,7 @@ import { discordProcessingHooks } from '../../discord/src/processing.mjs';
import { createContainerStopper } from '../../processing-control/src/containers.mjs';
// Row 194 (D3): the verbs the instance writer commits, whose answers carry restart.
const INSTANCE_VERBS = new Set(['instances.set', 'veto.set', 'instance.hire', 'instance.fire']);
const INSTANCE_VERBS = new Set(['instances.set', 'veto.set', 'instance.fire']);
// FED R4 (#1801, design 3.7): the revocation timer's period.
export const ACCESS_SWEEP_MS = 30_000;
// Trusted host API. S1 supplies resolved definitions, S6 supplies launch records and ends them.
@@ -71,10 +77,10 @@ export const ACCESS_SWEEP_MS = 30_000;
// web.fetch still works. A plug-in that refuses to load is logged and leaves search off; it never
// stops the broker. Under harness-builtin the harness serves web itself, so the broker gets no
// service and refuses the web verbs.
export async function webServices(businesses, { log = (e) => process.stderr.write(`plugins: ${JSON.stringify(e)}\n`), deps } = {}) {
export async function webServices(businesses, { log = (e) => process.stderr.write(`plugins: ${JSON.stringify(e)}\n`), deps, dataRoot = null } = {}) {
const services = new Map();
for (const [name, b] of Object.entries(businesses)) {
const loader = createLoader({ log });
const loader = createLoader({ log, dataRoot });
await loader.load(b.plugins ?? {});
const [source] = loader.modules('search-source');
// "harness-builtin" is served by the harness itself, not the broker: no broker web verbs.
@@ -90,7 +96,7 @@ export async function webServices(businesses, { log = (e) => process.stderr.writ
// checks a write runs, which are synchronous. A module that doesn't load, or answers anything but a
// list of names, answers null, which refuses the write. The tree is pinned, so a module changes only
// with a re-pin, and a re-pin restarts the bus.
export async function harnessLevels({ log = (e) => process.stderr.write(`plugins: ${JSON.stringify(e)}\n`) } = {}) {
export async function harnessLevels({ log = (e) => process.stderr.write(`plugins: ${JSON.stringify(e)}\n`), dataRoot = null } = {}) {
const levels = new Map();
let names = [];
try {
@@ -98,8 +104,12 @@ export async function harnessLevels({ log = (e) => process.stderr.write(`plugins
} catch (e) {
log({ event: 'harness-levels-unread', message: e.message });
}
if (dataRoot !== null) {
try { names = [...new Set([...names,...Object.keys(readPluginLock(dataRoot).plugins).filter(name=>hasModule('harness',name,dataRoot))])]; }
catch { log({event:'harness-levels-unread',message:'installed harness discovery refused'}); }
}
for (const name of names) {
const loader = createLoader({ log });
const loader = createLoader({ log, dataRoot });
if ((await loader.load({ harness: [name] })).refused.length) continue;
try {
const list = loader.modules('harness')[0].levels();
@@ -291,7 +301,7 @@ export async function startBroker({
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 });
const loadOrg = id => loadWithCopies(id, { dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot, 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');
@@ -406,20 +416,20 @@ export async function startBroker({
// vikunja:19/305: the plug-in switch through the same human-only path as the limits: the verbs
// plugins and plugins.set, logged in changes.jsonl with the actor, a missing module refused.
const plugins=organization?pluginSettings({dir:organization.dir,rolesDir:organization.rolesDir,exists:hasModule}):null;
const levels=organization?await harnessLevels():()=>null;
const plugins=organization?pluginSettings({dir:organization.dir,rolesDir:organization.rolesDir,dataRoot:instanceRoot ?? dataRoot,exists:(kind,name)=>hasModule(kind,name,instanceRoot ?? dataRoot)}):null;
const levels=organization?await harnessLevels({dataRoot:instanceRoot ?? dataRoot}):()=>null;
// Row 333 R4 (#1831): the writer's key lineage lookup, ids only, from this bus's own credential
// records. A daily key quota moved within one agent's lineage keeps its since and its count.
const keyLineage=(business,keyId)=>modelKeyLineage(credentials.status(),business,keyId);
const settings=organization?launchSettings({...organization,sessions:()=>readSessions(dataRoot),levels,keyLineage}):null;
modelCatalogues = modelCatalogue ?? (settings ? createModelCatalogue({ client: () => modelProxyClient, settings: id => settings.current(id).plugins ?? {}, proxyLock }) : null);
modelCatalogues = modelCatalogue ?? (settings ? createModelCatalogue({ pluginDataRoot:instanceRoot ?? dataRoot, client: () => modelProxyClient, settings: id => settings.current(id).plugins ?? {}, proxyLock }) : null);
// Resolve legacy table entries with the enabled modules' declarations. A
// missing or ambiguous mapping never becomes an available route.
async function catalogueFor(id, options = {}) {
const snapshot = settings.writer.read(id);
const current = settings.current(id);
const names = current.plugins?.['model-provider'] ?? [];
const loader = makeProviderLoader({log:()=>{}});
const loader = makeProviderLoader({dataRoot:instanceRoot ?? dataRoot,log:()=>{}});
const loaded = await loader.load({'model-provider': names});
const catalogue = await modelCatalogues.read(id, options);
if (loaded.refused.length || settings.writer.read(id).revision !== snapshot.revision) return {...catalogue, status:'unavailable', legacy:{}};
@@ -479,16 +489,16 @@ export async function startBroker({
return { revoked: services, outsideStore: how === 'fire' ? outside : [] };
}
// Row 165 (#1662): the human adds and changes role instances through instances and instances.set.
const proxyOrganization = modelKeys ? modelOrganization({ modelKeys, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }) }) : null;
const proxyOrganization = modelKeys ? modelOrganization({ modelKeys, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }) }) : null;
const instances=organization?instanceSettings({dir:organization.dir,rolesDir:organization.rolesDir,system:organization.system,levels,revoke,
...(proxyOrganization ? { writer: proxyOrganization.writer, revoke: proxyOrganization.revoke(revoke) } : {})}):null;
const usageLog = entry => process.stderr.write(`usage: ${JSON.stringify(entry)}\n`);
const keyForRun = (business, run) => modelRuns.get(business + ':' + run)?.keyId ?? null;
// Row 333 R3 (#1831): the usage view marks key quotas that match no hired agent; limits answers.
let orphanedKeys = () => null;
const usage = await createUsage({ store, dataRoot, businesses, log: usageLog, keyForRun,
const usage = await createUsage({ store, dataRoot, pluginDataRoot:instanceRoot ?? dataRoot, businesses, log: usageLog, keyForRun,
proxyView: business => proxyUsage?.view(business) ?? null, keyOrphans: business => orphanedKeys(business) });
proxyUsage = createProxyUsage({ store, log: usageLog, client: async () => {
proxyUsage = createProxyUsage({ store, pluginDataRoot:instanceRoot ?? dataRoot, log: usageLog, client: async () => {
if (!modelKeys) return null;
await modelKeys.verifyIdentity();
return modelProxyClient;
@@ -498,7 +508,7 @@ export async function startBroker({
// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): the limit checks read the organization file at
// each ask, so a limit, a reset or a switched-on check takes effect without a restart.
const limitLog = entry => process.stderr.write(`limits: ${JSON.stringify(entry)}\n`);
const limits = createLimits({ store, read: id => settings ? settings.current(id) : businesses[id], log: limitLog,
const limits = createLimits({ store, pluginDataRoot:instanceRoot ?? dataRoot, read: id => settings ? settings.current(id) : businesses[id], log: limitLog,
keyForSubject: (business, subject) => modelUsageKey(credentials.status(), modelRuns, business, subject),
keyLineages: business => modelKeyLineages(credentials.status(), business) });
orphanedKeys = business => limits.orphanedKeys(business);
@@ -591,7 +601,7 @@ export async function startBroker({
// receipt-backed addition under the organization lock: the file must still be this business plus that entry,
// with the credentials, bots, libraries, roles and prior mappings the bus booted with. Anything else leaves
// the product pending. Both readiness records must exist before even the broker's reload begins.
const writer = organization ? organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }) : null;
const writer = organization ? organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }) : null;
const known = new Map(Object.entries(businesses).map(([id, b]) => [id, canonicalJson(b.projects ?? {})]));
function activateManagedProject({ business: id, project, receipt, organization_revision }) {
if (!writer || !booted.has(id) || !Object.hasOwn(businesses, id)) throw new BusError('unknown-business');
@@ -692,6 +702,7 @@ export async function startBroker({
trackerOf.set(id, named ?? ((trackers ? tracked : tasks !== null) ? 'vikunja' : 'store'));
}
broker = new Broker({
pluginService: pluginService({dataRoot:instanceRoot ?? dataRoot,read:id=>organization ? loadOrg(id) : businesses[id]}),
taskTracker: (id) => trackerOf.get(id),
workspaceRead: state => requestWorkspace({ dataRoot, state, events: store.all("SELECT kind,actor_role,subject,body FROM events WHERE business=? AND kind IN ('task.created','task.assigned','session.launched') ORDER BY seq LIMIT 10001", state.business) }),
requirementsHost,
@@ -760,7 +771,7 @@ export async function startBroker({
// configured reads as unreadable, so requireCitation is true), and thread.messages' `last`.
guard: (context, body, options) => libraryModule.guardReply({ ...context, dataRoot }, body, options),
replyLimits: id => libraryModule.resolveReplyLimits(organization
? organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }).readRaw(id) : { readable: false }),
? organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }).readRaw(id) : { readable: false }),
last: (context, paths) => libraryModule.lastCommits({ ...context, dataRoot }, paths),
GuardError: libraryModule.GuardError,
},
@@ -791,7 +802,7 @@ export async function startBroker({
known.set(id, canonicalJson(next.projects));
return getProjectRemote(writer, id, args.project);
}
if (verb === 'system-types.installed') return installedSystemTypes();
if (verb === 'system-types.installed') return installedSystemTypes({dataRoot:instanceRoot ?? dataRoot});
if (verb === 'system-types.current') return settings.current(id).plugins?.['system-type'] ?? [];
if(verb==='limits')return withTokens(id,settings.read(id));
if(verb==='plugins')return {...plugins.read(id), available:{harness:levels.names ?? []}};
@@ -809,8 +820,6 @@ export async function startBroker({
if(verb==='instances.check')return instances.checkDelete(id,args.instance,actor);
// Row 194 b6 (webby-0094 B1): a fire's checks against the file, before step A.
if(verb==='instance.fireCheck')return instances.checkFire(id,args.name);
// Internal read-only path: no reload, journal, credential or publication.
if(verb==='instance.hireCheck')return instances.checkHire(id,args);
let result;
if (['limits.set', 'instances.set'].includes(verb) && settings.current(id).plugins?.['model-provider']?.length) {
return catalogueFor(id, { fresh: true }).then(catalogue => {
@@ -832,16 +841,21 @@ export async function startBroker({
});
result=settings.setRoutes(id,args,actor);
}
else if(verb==='plugins.set'){result=plugins.set(id,args,actor);modelCatalogues?.invalidate();}
else if(verb==='plugins.set') {
if(actor==='tester' && Object.entries(args.plugins ?? {}).some(([kind,value])=>(Array.isArray(value)?value:[value]).some(name=>name!==null && !hasModule(kind,name)))) throw new BusError('human-required');
result=plugins.set(id,args,actor);modelCatalogues?.invalidate();
}
// Row 194 (#1692, V6): the human's keep and noHire lists, through the instance writer.
else if(verb==='veto.set')result=instances.veto(id,args,actor);
// Row 298: the human's grant scope, through the instance writer.
else if(verb==='grants.scope.set')result=instances.grantScope(id,args,actor);
// C2-06 M11: the human's replyLimits, through the organization writer's fence.
else if(verb==='library.limits.set')result=setReplyLimits(organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir}),id,args,actor);
// #1873 T1: the human's definition writes. The broker passes only the business's human (the
// owner, so ROLE_RIGHTS.owner); the store's assertClean is the held-secret check each write needs.
else if(Object.hasOwn(DEFINITION_BUS_VERBS,verb))result=definitionVerbs({writer:organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir}),rolesDir:organization.rolesDir,assertClean:value=>credentials.assertClean(value)})[DEFINITION_BUS_VERBS[verb]](id,args,{actor,rights:[...ROLE_RIGHTS.owner],agent:false});
// Row 194 (D2.1, D2.2): the chief's hire and fire, with the reason in the change context.
else if(verb==='instance.hire')result=instances.hire(id,args,{actor,context});
else if(verb==='instance.fire')result=instances.fire(id,{name:args.name},{actor,context});
else if(verb==='instance.fire')result=instances.fire(id,{name:args.name,revision:args.revision},{actor,context});
else throw new BusError('invalid-request');
// Row 194 (D3 item 2): one reload, after every verb that committed, then the limit checks.
const live=reload(id);
@@ -852,6 +866,24 @@ export async function startBroker({
return INSTANCE_VERBS.has(verb)?{...result,restart:!live}:result;
}):null,
// Row 194 (D2.3): the role files as they stand, for the hire checks.
hires: organization ? (()=>{
const service=hireDecisions({writer:organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir,dataRoot:instanceRoot??dataRoot}),rolesDir:organization.rolesDir,configDir:organization.dir,dataRoot,
system:organization.system,levels,catalogueFor:async(id,doc)=>doc.plugins?.['model-provider']?.length?catalogueFor(id,{fresh:true}):null,assertClean:value=>credentials.assertClean(value),registryFor:doc=>organizationPlugins(instanceRoot??dataRoot,doc.plugins).registry});
return {...service,apply:async(id,decision,caller)=>{
const proposal=service.read(decision);
const publish=async()=>{
let approved=caller;
if(modelKeys && proposal.op==='fire' && proposal.level!=='org') {
const instance=`${id}/${proposal.level==='project'?`${proposal.name}.${proposal.project}`:proposal.name}`;
await modelKeys.prepareRevoke(instance);
approved={...caller,beforeWrite:()=>modelKeys.completeRevoke(instance)};
}
const result=await service.apply(id,decision,approved);reload(id);return result;
};
if(proxyOrganization && proposal.level==='org' && proposal.op==='hire')return proxyOrganization.hire(id,proposal.name,()=>service.check(id,decision,caller),publish);
return publish();
}};
})() : null,
roleFile: organization ? (name) => loadRole(organization.rolesDir, name) : null,
secretCheck: (value) => credentials.assertClean(value),
credentialStatus: () => credentials.status(),
@@ -868,7 +900,7 @@ export async function startBroker({
return settings;
} : null,
credentialVerbs: organization
? credentialVerbs({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }) })
? credentialVerbs({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }) })
: null,
// Row 65: a new launch binds only on a harness its business switches on, read from the file
// as it stands now; an unreadable file switches none on.
@@ -941,7 +973,7 @@ export async function startBroker({
let factory = null;
if (name === 'vikunja' && tasks) factory = tasks;
else if (name !== 'vikunja' || [...Object.values(businesses)].some((b) => b?.plugins?.['task-tracker'] === 'vikunja')) {
const loader = createLoader({ log: pluginLog });
const loader = createLoader({ dataRoot:instanceRoot ?? dataRoot, log: pluginLog });
const { refused } = await loader.load({ 'task-tracker': name });
const [module] = loader.modules('task-tracker');
if (!refused.length && module) factory = module.adapter({ trackers: trackers ?? {} });
@@ -963,7 +995,7 @@ export async function startBroker({
return one.handle(cap, verb, args);
};
}
const web = await webServices(businesses);
const web = await webServices(businesses,{dataRoot:instanceRoot ?? dataRoot});
// Row 210 (#1707, design D7 step 6): clear what a killed bus child left, before anyone can call.
if (runRoot) sweepRunDir(runRoot);
// Row 234 (r6 X3): end the enrollments a killed bus child left, before anyone can call.
@@ -1216,13 +1248,13 @@ export async function startBroker({
if (vars[prefix + 'model'] !== modelKey || vars[prefix + 'harness'] !== harness) return refused('model-provider-unavailable');
const entry = current.launch?.models?.[modelKey];
if (!entry || canonicalJson(entry) !== canonicalJson(expected ?? null) || entry.provider !== provider || entry.model !== model) return refused('model-provider-unavailable');
const harnessLoader = createLoader({log:()=>{}});
const harnessLoader = createLoader({dataRoot:instanceRoot ?? dataRoot,log:()=>{}});
if ((await harnessLoader.load({harness:[harness]})).refused.length) return refused('model-provider-unavailable');
const countsAccount = harnessLoader.modules('harness')[0].countsAccount();
if (typeof countsAccount !== 'boolean' || account !== (countsAccount ? current.launch?.account?.name ?? null : null)) return refused('model-provider-unavailable');
const names = current.plugins?.['model-provider'];
if (!Array.isArray(names) || !names.length || (entry.module && !names.includes(entry.module))) return refused('model-provider-unavailable');
const loader = makeProviderLoader({log:()=>{}}), loaded = await loader.load({'model-provider':entry.module ? [entry.module] : names});
const loader = makeProviderLoader({dataRoot:instanceRoot ?? dataRoot,log:()=>{}}), loaded = await loader.load({'model-provider':entry.module ? [entry.module] : names});
if (loaded.refused.length) return refused('model-provider-unavailable');
const selected = selectModelProvider(loader.snapshot('model-provider'), entry);
if (!selected) return refused('model-provider-unavailable');
@@ -1239,7 +1271,7 @@ export async function startBroker({
return refused(['model-provider-unavailable','model-catalogue-unavailable','model-harness-incompatible'].includes(answer?.code) ? answer.code : 'model-provider-unavailable');
},
modelPreflight: async ({ business, role }) => {
const current = settings ? settings.current(business) : businesses[business];
const current = organization ? loadOrg(business) : businesses[business];
if (!Object.hasOwn(current?.roles ?? {}, role)) throw new BusError('unknown-role');
return modelKeys ? modelKeys.preflight(business + '/' + role) : null;
},
+2 -2
View File
@@ -53,10 +53,10 @@ export function turnRecords(store, business) {
};
});
}
export async function createUsage({ store, businesses, log, keyForRun = () => null, proxyView = () => null, keyOrphans = () => null }) {
export async function createUsage({ store, businesses, pluginDataRoot = null, log, keyForRun = () => null, proxyView = () => null, keyOrphans = () => null }) {
const loaders = new Map();
for (const [id, b] of Object.entries(businesses)) {
const loader = createLoader({ log });
const loader = createLoader({ log, dataRoot:pluginDataRoot });
await loader.load(b.plugins ?? {});
loaders.set(id, loader);
}
+6 -370
View File
@@ -8,7 +8,6 @@ import { DatabaseSync } from 'node:sqlite';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { Store, schemaDigest } from '../src/store.mjs';
import { Broker, ACTIONS } from '../src/broker.mjs';
import { HIRE_ALLOWED } from '../../organization/src/vocabulary.mjs';
import { startBroker } from '../src/runtime.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
import { busOrganization } from '../src/organization-context.mjs';
@@ -711,380 +710,17 @@ test('row 194 T18 (D2.4): from a chief session veto.set and instances.set refuse
assert.deepEqual(organization.at(-1), ['demo', 'veto.set', { revision: PATCH.revision, op: 'unkeep', name: 'coder' }, 'jason']);
});
// Row 194 (#1692, D2.1-D2.3): the chief's hire and fire. PRD 1.7 REQ-COS-3 routes role.hire and
// role.fire within-role for the chief (sage-1471, mos-1089), so they take the reason form, as
// role.end does: no decision, a reason of 1 to 500 characters that goes into the log. The role
// files are stubs, so a definition's authority is the test's to set.
const LISTED = { launch: { by: 'chief', instances: ['pm', 'coder'], keep: ['pm'], noHire: ['banned'] } };
const roleDoc = (withinRole, extra = {}) => ({ roleVersion: 2, authority: { withinRole, crossRole: [] }, hireable: [], ...extra });
const ROLE_FILES = {
'chief-of-staff': roleDoc(chiefAuthority, { hireable: ['coder', 'researcher', 'wide', 'banned'] }),
coder: roleDoc(['message.send', 'record.write']),
// A version 1 file has empty authority lists, so only the version check refuses it (M60).
researcher: { roleVersion: 1, authority: { withinRole: [], crossRole: [] }, hireable: [] },
wide: roleDoc(['message.send', 'task.create']),
banned: roleDoc(['message.send']),
pm: roleDoc(['message.send']),
};
const roleFile = (files) => (name) => {
if (!Object.hasOwn(files, name)) throw Object.assign(new Error(`no role ${name}`), { name: 'BusinessError' });
return files[name];
};
function hireSetup(t, extra = LISTED, files = ROLE_FILES) {
return setup(t, extra, { roleFile: roleFile(files) });
}
const HIRE = { name: 'helper', definition: 'coder', reason: 'more hands for the queue' };
const FIRE = { name: 'coder', reason: 'the work is done' };
const CONTEXT = (reason) => ({ instance: 'chief', run: 'chief-run', reason });
const withoutReason = ({ reason, ...rest }) => rest;
test('row 194 T8: the chief hires with a reason and no decision, the writer gets the reason as context, and the log names it', (t) => {
const { call, organization, allowed, store } = hireSetup(t);
const result = call('chief', 'instance.hire', HIRE);
assert.equal(result.transaction, 't-1');
assert.deepEqual(organization, [['demo', 'instance.hire', withoutReason(HIRE), 'chief-of-staff.chief', CONTEXT(HIRE.reason)]]);
const [use, ...rest] = allowed('role.hire');
assert.equal(rest.length, 0);
assert.equal(use.subject, 'helper');
assert.deepEqual({ reason: use.body.reason, transaction: use.body.transaction, decision: use.body.decision ?? null }, { reason: HIRE.reason, transaction: 't-1', decision: null });
// Within-role: no decision is raised or needed.
assert.equal(store.get('SELECT count(*) AS n FROM decisions').n, 0);
// vars reach the writer with the name and definition.
call('chief', 'instance.hire', { ...HIRE, name: 'aide', vars: { model: 'm', 'limits.x': 1 } });
assert.deepEqual(organization.at(-1)[2], { name: 'aide', definition: 'coder', vars: { model: 'm', 'limits.x': 1 } });
});
// T11 (D5): a business id names one business on one bus. The chief's hire and fire go to its own
// business's writer, and no argument names another.
test('row 194 T11: on a bus with two businesses, the chief of one hires and fires only in its own', (t) => {
const root = mkdtempSync(join(tmpdir(), 'bus-chief-two-'));
const store = new Store(root);
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const organization = [];
const one = demo(LISTED).demo;
const b = new Broker({
store,
businesses: { demo: one, other: { ...one, id: 'other' } },
roleFile: roleFile(ROLE_FILES),
organization: (...args) => (organization.push(args), { revision: 'b'.repeat(64), transaction: 't-' + organization.length }),
});
const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
b.request(chief, { verb: 'role.claim' });
for (const verb of ['instance.hire', 'instance.fire'])
for (const extra of [{ business: 'other' }, { target: 'other' }])
assert.throws(() => b.request(chief, { verb, args: { ...(verb === 'instance.hire' ? HIRE : FIRE), ...extra } }), /invalid-request/, `${verb} ${Object.keys(extra)}`);
assert.equal(organization.length, 0);
b.request(chief, { verb: 'instance.hire', args: HIRE });
b.request(chief, { verb: 'instance.fire', args: FIRE });
assert.deepEqual(organization.map((c) => [c[0], c[1]]), [['demo', 'instance.hire'], ['demo', 'instance.fireCheck'], ['demo', 'instance.fire']]);
assert.equal(store.get("SELECT count(*) AS n FROM events WHERE business='other'").n, 0);
});
test('row 194 T8: hire and fire need a reason of 1 to 500 characters and refuse a decision key', (t) => {
const { call, organization } = hireSetup(t);
for (const reason of [undefined, '', 'x'.repeat(501), 7]) {
const r = reason === undefined ? {} : { reason };
assert.throws(() => call('chief', 'instance.hire', { name: 'helper', definition: 'coder', ...r }), /invalid-request/, String(reason));
assert.throws(() => call('chief', 'instance.fire', { name: 'coder', ...r }), /invalid-request/, String(reason));
}
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, decision: 'd-1' }), /invalid-request/);
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, decision: 'd-1' }), /invalid-request/);
for (const name of ['human', 'tester']) assert.throws(() => call('chief', 'instance.hire', { ...HIRE, name }), /invalid-request/, name);
assert.equal(organization.length, 0);
call('chief', 'instance.hire', { ...HIRE, reason: 'x'.repeat(500) });
assert.equal(organization.length, 1);
});
test('row 194 T8 (sage-1471): only the chief hires or fires; every other role refuses role-not-chief, even one granted the actions', (t) => {
const granted = { authority: { withinRole: ['message.send', 'role.hire', 'role.fire'], crossRole: [] } };
const { call, organization, allowed } = hireSetup(t, { ...LISTED, roles: { ...roles, pm: { definition: 'pm', ...granted }, coder: { definition: 'coder', ...granted } } });
for (const who of ['pm', 'coder', 'assistant']) {
assert.throws(() => call(who, 'instance.hire', HIRE), /role-not-chief/, who);
assert.throws(() => call(who, 'instance.fire', FIRE), /role-not-chief/, who);
}
assert.throws(() => call('tester', 'instance.hire', HIRE), /tester-refused/);
assert.throws(() => call('reader', 'instance.hire', HIRE), /read-only/);
assert.throws(() => call('human', 'instance.fire', FIRE), /agent-required/);
assert.equal(organization.length, 0);
assert.equal(allowed('role.hire').length + allowed('role.fire').length, 0);
});
test('row 194 T8 (sage-1471): a chief whose authority lacks role.hire or role.fire refuses decision-required before the writer', (t) => {
for (const missing of ['role.hire', 'role.fire']) {
const authority = { withinRole: chiefAuthority.withinRole.filter((a) => a !== missing), crossRole: [] };
const { call, organization, lastClaim } = hireSetup(t, { ...LISTED, roles: { ...roles, chief: { definition: 'chief-of-staff', authority } } });
const [verb, args] = missing === 'role.hire' ? ['instance.hire', HIRE] : ['instance.fire', FIRE];
assert.throws(() => call('chief', verb, args), /decision-required/, missing);
assert.equal(organization.length, 0, missing);
assert.equal(lastClaim('coder').op, 'claim', missing);
// The other action still goes through.
const [other, otherArgs] = missing === 'role.hire' ? ['instance.fire', FIRE] : ['instance.hire', HIRE];
call('chief', other, otherArgs);
assert.equal(organization.filter(([, verb]) => verb !== 'instance.fireCheck').length, 1, missing);
}
});
test('row 194 T8a: a re-hire of a fired name goes to the writer like a hire, and the writer\'s refusals pass through by name', (t) => {
const { call, organization, writer } = hireSetup(t);
const again = { name: 'coder', definition: 'coder', reason: 'needed again' };
for (const code of ['organization-instance-exists', 'organization-instance-retired', 'definition-not-hireable', 'instance-kept']) {
writer.error = Object.assign(new Error(code), { code });
assert.throws(() => call('chief', 'instance.hire', again), new RegExp(code));
}
writer.error = null;
call('chief', 'instance.hire', again);
assert.deepEqual(organization.at(-1).slice(1, 3), ['instance.hire', withoutReason(again)]);
});
test('row 194 T8b: a definition is hireable only when every action is on HIRE_ALLOWED, it is version 2, the chief lists it and noHire doesn\'t', (t) => {
assert.deepEqual([...HIRE_ALLOWED], ['message.send', 'record.write', 'review.request', 'task.update.assigned', 'task.scope.change', 'git.push.working']);
for (const action of ACTIONS) {
const files = { ...ROLE_FILES, wide: roleDoc(['message.send', action]) };
const { call, organization } = hireSetup(t, LISTED, files);
const run = () => call('chief', 'instance.hire', { ...HIRE, definition: 'wide' });
if (HIRE_ALLOWED.includes(action)) {
run();
assert.equal(organization.length, 1, action);
} else {
assert.throws(run, /definition-not-hireable/, action);
assert.equal(organization.length, 0, action);
}
}
const { call, organization } = hireSetup(t);
const hire = (definition) => call('chief', 'instance.hire', { ...HIRE, definition });
// Version 1 (M60), noHire, not on the chief's list, and no role file.
for (const definition of ['researcher', 'banned', 'pm', 'nothing'])
assert.throws(() => hire(definition), /definition-not-hireable/, definition);
assert.equal(organization.length, 0);
hire('coder');
assert.equal(organization.length, 1);
});
test('row 194 T8b: a hire without the role files configured refuses not-configured', (t) => {
const { call, organization } = setup(t, LISTED);
assert.throws(() => call('chief', 'instance.hire', HIRE), /not-configured/);
assert.equal(organization.length, 0);
assert.throws(() => new Broker({ store: null, businesses: demo(), roleFile: 'roles' }), /roleFile must be a function/);
});
test('row 194 T9: a fire revokes the claim, cancels open requests, asks the launcher to kill the runs, then delists and logs', (t) => {
const { call, organization, allowed, lastClaim, writer } = hireSetup(t);
// An open launch request of the coder, made by the human.
const open = call('human', 'instance.launch', { instance: 'coder' }).request;
let seen = null;
writer.during = ([, verb]) => { if (verb === 'instance.fire') seen = { stopped: allowed('fire.stopped').length, claim: lastClaim('coder').op, used: allowed('role.fire').length }; };
const result = call('chief', 'instance.fire', FIRE);
// Step A ran before the writer; step C after it.
assert.deepEqual(seen, { stopped: 1, claim: 'revoke', used: 0 });
assert.deepEqual(lastClaim('coder'), { op: 'revoke', decision: null, reason: 'the work is done', by: 'chief-run' });
assert.deepEqual(result.runs, ['coder-run']);
assert.deepEqual(result.cancelled, [open]);
assert.deepEqual(organization.at(-1), ['demo', 'instance.fire', { name: 'coder' }, 'chief-of-staff.chief', CONTEXT(FIRE.reason)]);
const [end] = allowed('lifecycle.end');
assert.equal(end.subject, 'coder');
assert.deepEqual({ ...end.body, request: null }, { action: 'lifecycle.end', request: null, via: 'chief', by: 'chief', runs: ['coder-run'], reason: 'the work is done', kill: true });
assert.equal(allowed('lifecycle.cancelled').filter((e) => e.body.request === open).length, 1);
const [use] = allowed('role.fire');
assert.equal(use.subject, 'coder');
assert.equal(use.body.reason, 'the work is done');
assert.equal(use.body.transaction, result.transaction);
assert.deepEqual(allowed('fire.done').map((e) => [e.subject, e.body.transaction]), [['coder', result.transaction]]);
// The human's read of the request shows the kill.
assert.equal(call('human', 'instance.request', { request: end.body.request }).kill, true);
});
// S6 (D2.2): the launcher closes a fire's end with the runs row 191's stop killed or left alive, and
// each becomes its own event against the instance. Only a fire's end carries them.
test('row 194 T9a (bus): a fire end\'s done writes lifecycle.end.killed and lifecycle.end.alive with run and pid, and an unknown stop\'s reason; a terminate\'s done or a bad stop refuses', (t) => {
const { b, call, allowed } = hireSetup(t);
call('chief', 'instance.fire', FIRE);
const [end] = allowed('lifecycle.end');
const request = end.body.request;
assert.equal(b.lifecycleTake({ business: 'demo', request }).go, true);
const done = (stops) => b.lifecycleDone({ business: 'demo', request, stops });
for (const bad of [[{ run: 'coder-run', pid: 4242, outcome: 'stopped' }], [{ run: 'coder-run', pid: 1, outcome: 'killed' }],
[{ run: 'coder-run', outcome: 'killed' }], [{ run: 'coder-run', pid: 4242, outcome: 'killed', signals: [] }], 'killed',
// sage-2289: only an alive stop carries a reason, and only unknown.
[{ run: 'coder-run', pid: 4242, outcome: 'killed', reason: 'unknown' }], [{ run: 'coder-run', pid: null, outcome: 'alive', reason: 'stuck' }]])
assert.throws(() => done(bad), { code: 'invalid-request' }, JSON.stringify(bad));
const closed = done([{ run: 'coder-run', pid: 4242, outcome: 'killed' }, { run: 'coder-other', pid: null, outcome: 'alive' },
{ run: 'coder-unknown', pid: null, outcome: 'alive', reason: 'unknown' }]);
assert.equal(closed.state, 'closed');
assert.deepEqual(allowed('lifecycle.end.killed').map((e) => [e.subject, e.body]), [['coder', { action: 'lifecycle.end.killed', request, run: 'coder-run', pid: 4242 }]]);
assert.deepEqual(allowed('lifecycle.end.alive').map((e) => [e.subject, e.body]), [['coder', { action: 'lifecycle.end.alive', request, run: 'coder-other', pid: null }],
['coder', { action: 'lifecycle.end.alive', request, run: 'coder-unknown', pid: null, reason: 'unknown' }]]);
// A terminate's end sends SIGTERM only, so its done has no stops.
const term = call('human', 'instance.end', { instance: 'pm' }).request;
assert.equal(b.lifecycleTake({ business: 'demo', request: term }).go, true);
assert.throws(() => b.lifecycleDone({ business: 'demo', request: term, stops: [{ run: 'pm-run', pid: 4242, outcome: 'killed' }] }), { code: 'invalid-request' });
assert.equal(allowed('lifecycle.end.killed').length, 1);
assert.equal(b.lifecycleDone({ business: 'demo', request: term }).state, 'closed');
});
test('row 194 T9: fire refusals come before step A and leave the claim, the requests and the file alone', (t) => {
const { call, organization, allowed, lastClaim } = hireSetup(t, { launch: { by: 'chief', instances: ['coder'], keep: ['pm'] } });
const fire = (name) => call('chief', 'instance.fire', { name, reason: 'r' });
assert.throws(() => fire('pm'), /instance-kept/);
assert.throws(() => fire('nobody'), /unknown-role/);
assert.throws(() => fire('assistant'), /organization-definition-refused/);
assert.throws(() => fire('chief'), /organization-instance-referenced/);
assert.throws(() => fire('coder'), /invalid-request/);
assert.equal(organization.length, 0);
assert.equal(allowed('fire.stopped').length + allowed('lifecycle.end').length + allowed('role.fire').length, 0);
assert.equal(lastClaim('coder').op, 'claim');
});
test('row 194 T9c: a writer failure at B leaves the instance firing, and the repeat delists and logs once', (t) => {
const { call, b, caps, organization, allowed, writer } = hireSetup(t);
writer.during = ([, verb]) => { if (verb === 'instance.fire') throw new Error('disk'); };
assert.throws(() => call('chief', 'instance.fire', FIRE), /outcome-unknown/);
writer.during = null;
assert.equal(allowed('fire.stopped').length, 1);
assert.equal(allowed('role.fire').length, 0);
// W-N1: while it fires, no launch of the instance starts by any path.
assert.throws(() => call('human', 'instance.launch', { instance: 'coder' }), /instance-firing/);
assert.throws(() => b.authorize(caps.chief, 'role.launch', { target: 'coder' }), /instance-firing/);
// The repeat has no claim to revoke. The revoked run is still bound until the launcher reports it
// ended, so the repeat asks for its kill again, then delists and logs.
const result = call('chief', 'instance.fire', FIRE);
assert.deepEqual(result.runs, ['coder-run']);
assert.equal(organization.filter(([, verb]) => verb === 'instance.fire').length, 2);
assert.equal(allowed('role.fire').length, 1);
assert.deepEqual(allowed('lifecycle.end').map((e) => e.body.kill), [true, true]);
// Once done, the instance launches again.
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
});
test('row 194 T9c: a failure at C leaves the fire unlogged and the instance firing, and the repeat logs it', (t) => {
const { call, organization, allowed, store, writer } = hireSetup(t);
// The delist commits, then the step C transaction aborts.
writer.during = ([, verb]) => verb === 'instance.fire' && store.run("CREATE TRIGGER fail_c BEFORE INSERT ON events WHEN json_extract(new.body,'$.action')='fire.done' BEGIN SELECT RAISE(ABORT, 'c failed'); END");
assert.throws(() => call('chief', 'instance.fire', FIRE));
writer.during = null;
store.run('DROP TRIGGER fail_c');
assert.equal(allowed('role.fire').length, 0);
assert.equal(allowed('fire.done').length, 0);
assert.throws(() => call('human', 'instance.launch', { instance: 'coder' }), /instance-firing/);
call('chief', 'instance.fire', FIRE);
assert.equal(organization.filter(([, verb]) => verb === 'instance.fire').length, 2);
assert.equal(allowed('role.fire').length, 1);
assert.equal(allowed('fire.done').length, 1);
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
});
test('row 194 W-N1: a fire.done before a later fire.stopped of the same instance leaves it firing; another instance\'s done does not end it', (t) => {
const { call, store } = hireSetup(t);
const step = (instance, action) =>
store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,'demo','action.allowed','chief','chief-run',?,?)",
`${action}-${instance}-${Math.random()}`, new Date().toISOString(), instance, JSON.stringify({ action, request: null, transaction: null }));
step('coder', 'fire.done');
step('coder', 'fire.stopped');
step('pm', 'fire.done');
assert.throws(() => call('human', 'instance.launch', { instance: 'coder' }), /instance-firing/);
step('coder', 'fire.done');
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
});
test('row 194 T10: a hire or fire of a kept name refuses instance-kept before the writer', (t) => {
const { call, organization, lastClaim, allowed } = hireSetup(t);
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, name: 'pm' }), /instance-kept/);
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, name: 'pm' }), /instance-kept/);
assert.equal(organization.length, 0);
assert.equal(lastClaim('pm').op, 'claim');
assert.equal(allowed('role.hire').length + allowed('role.fire').length, 0);
});
// webby-0094 B1: the file's keep, which this copy hasn't loaded, refuses at the fire check, before
// step A revokes, ends or marks anything, so the instance isn't left firing.
test('row 194 T10: a fire the writer\'s check refuses from the file stops nothing and leaves the instance launchable', (t) => {
const { call, organization, lastClaim, allowed, writer } = hireSetup(t);
writer.during = ([, verb]) => { if (verb === 'instance.fireCheck') throw Object.assign(new Error('instance-kept'), { code: 'instance-kept' }); };
assert.throws(() => call('chief', 'instance.fire', FIRE), /instance-kept/);
writer.during = null;
assert.deepEqual(organization.map(([, verb]) => verb), ['instance.fireCheck']);
assert.equal(lastClaim('coder').op, 'claim');
assert.equal(allowed('fire.stopped').length + allowed('lifecycle.end').length + allowed('role.fire').length, 0);
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
});
test('row 194 T18: hire and fire take only their own keys, and a refused call writes nothing', (t) => {
const { call, organization, allowed } = hireSetup(t);
for (const extra of [{ keep: ['x'] }, { launch: {} }, { revision: 'a'.repeat(64) }, { force: true }]) {
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, ...extra }), /invalid-request/, JSON.stringify(extra));
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, ...extra }), /invalid-request/, JSON.stringify(extra));
}
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, vars: { harness: 'pi' } }), /invalid-request/);
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, definition: 'coder' }), /invalid-request/);
assert.equal(organization.length, 0);
assert.equal(allowed('fire.stopped').length, 0);
});
test('row 194 T8, T9, T18: through the runtime the chief hires and fires with the real writer, the receipt carries the reason, and a refusal leaves the file alone', async (t) => {
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\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(readFileSync(chiefFile, 'utf8')), hireable: ['aide'] });
const file = join(root, 'businesses/demo.json');
writeJson(file, { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } });
mkdirSync(join(root, 'data'), { mode: 0o700 });
// The boot copy is the file's own bus form, as cli bootConfig builds it, so a write reloads.
const busForm = (dir, roles) => {
const b = loadBusiness('demo', { dir, rolesDir: roles });
const resolved = {};
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: systemFor(dir), business: b, instance });
return busOrganization(b, resolved);
};
const bus = await startBroker({
dataRoot: join(root, 'data'),
businesses: { demo: busForm(root, rolesDir) },
organization: { dir: root, rolesDir, system: systemFor(root) },
});
t.after(async () => {
await bus.close();
rmSync(root, { recursive: true, force: true });
});
const { broker } = bus;
const chief = broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
broker.request(chief, { verb: 'role.claim' });
const call = (cap, verb, args) => broker.request(cap, { verb, args });
const receipts = () =>
readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').map(JSON.parse).filter((r) => r.state === 'committed');
const document = () => JSON.parse(readFileSync(file, 'utf8'));
// The shipped chief file grants both actions, so the runtime takes the reason form.
// A refused hire: coder declares credentials and isn't on the chief's list. The file is unchanged.
const bytes = readFileSync(file, 'utf8');
assert.throws(() => call(chief, 'instance.hire', { name: 'helper', definition: 'coder', reason: 'r' }), /definition-not-hireable/);
assert.equal(readFileSync(file, 'utf8'), bytes);
call(chief, 'instance.hire', { name: 'helper', definition: 'aide', reason: 'more hands' });
assert.deepEqual(document().roles.helper, { definition: 'aide' });
assert.ok(document().launch.instances.includes('helper'));
const [h] = receipts().slice(-1);
assert.equal(h.actor, 'chief-of-staff.chief');
assert.deepEqual(h.context, CONTEXT('more hands'));
const result = call(chief, 'instance.fire', { name: 'helper', reason: 'the work is done' });
assert.equal(result.delisted, true);
assert.ok(!document().launch.instances.includes('helper'));
assert.deepEqual(receipts().slice(-1)[0].context, CONTEXT('the work is done'));
// The reloaded business sees the delist: a refused call leaves the file alone.
const after = readFileSync(file, 'utf8');
assert.throws(() => call(chief, 'instance.fire', { name: 'helper', reason: 'again', extra: 1 }), /invalid-request/);
assert.equal(readFileSync(file, 'utf8'), after);
test('A4 the retired lifecycle verbs always require a decision and never call the writer',t=>{
const {call,organization}=setup(t,KEPT);
for(const who of ['chief','coder','human'])for(const verb of ['instance.hire','instance.fire'])
assert.throws(()=>call(who,verb,{name:'coder',definition:'coder',reason:'help'}),{code:'decision-required'});
assert.equal(organization.length,0);
});
test('row 194 W-N1: the launcher\'s take of a launch or regenerate of a firing instance closes it as instance-firing', (t) => {
const { b, call, store } = hireSetup(t);
const { b, call, store } = setup(t,LISTED);
const launch = call('human', 'instance.launch', { instance: 'coder' }).request;
const regenerate = call('human', 'instance.regenerate', { instance: 'pm' }).request;
// A fire's step A as the store holds it with no step C, for requests step A didn't see.
+1 -1
View File
@@ -89,7 +89,7 @@ function setup(t) {
// #797 / #1839 adds the owner-only artifact read to the existing decision and request controls;
// no unsuffixed verb is ever a console verb.
test('N4: owner Console verbs have an explicit immutable whitelist and exclude decision.resolve', () => {
assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']);
assert.ok(CONSOLE_VERBS.every(verb => verb.endsWith('.console')));
assert.equal(CONSOLE_VERBS.includes('decision.resolve'), false);
@@ -0,0 +1,74 @@
// #1873 T1: the definition writes on the bus. Only the business's human at the cli reaches them,
// and the writer runs the credential store's held-secret check on what it would write.
import test from 'node:test';
import assert from 'node:assert/strict';
import { appendFileSync, chmodSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DEFINITION_BUS_KEYS } from '../src/broker.mjs';
import { startBroker } from '../src/runtime.mjs';
import { DEFINITION_BUS_VERBS } from '../../organization/src/definition-verbs.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
const TOKEN = 'fixture-held-token-t1-5c0e9a';
const SCOUT = { role: { tools: ['read'], network: 'none', authority: { withinRole: [], crossRole: [] }, credentials: [] }, instructions: 'Read and report.\n' };
function files(dir) {
return readdirSync(dir, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? files(join(dir, e.name)) : e.isFile() ? [join(dir, e.name)] : []);
}
test('T1 the broker checks the same four definition verbs the runtime maps', () => {
assert.deepEqual(Object.keys(DEFINITION_BUS_KEYS).sort(), Object.keys(DEFINITION_BUS_VERBS).sort());
});
test('T1 definition writes: the human at the cli writes, every other caller refuses, and a held token never lands', async (t) => {
const root = mkdtempSync(join(tmpdir(), 'mosaic-def-bus-'));
const rolesDir = rolesCopy(root);
const doc = businessDoc(root, 'demo');
const file = writeJson(join(root, 'businesses/demo.json'), { ...doc, plugins: { ...doc.plugins, harness: ['pi'] } });
const data = join(root, 'data');
mkdirSync(data, { mode: 0o700 });
const token = join(root, 'held.token');
writeFileSync(token, TOKEN, { mode: 0o600 });
chmodSync(token, 0o600);
const bus = await startBroker({ dataRoot: data, 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: [] }, credentials: { gitea: { file: token, rotateBy: '2099-01-01' } } },
} } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
const b = bus.broker;
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const revision = () => b.request(human, { verb: 'limits' }).revision;
const invent = (name) => ({ verb: 'definition.invent', args: { revision: revision(), name, definition: SCOUT } });
// An agent, a reader and the tester never reach the writer.
const before = readFileSync(file);
const agent = b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi', address: 'coder-run' });
for (const cap of [agent, b.bindReader({ business: 'demo' })]) assert.throws(() => b.request(cap, invent('scout')), /human-required/);
assert.throws(() => b.request(b.bindTester({ business: 'demo' }), invent('scout')), /tester-refused/);
// Unknown and missing arguments refuse; an actor can't be forged through them.
assert.throws(() => b.request(human, { verb: 'definition.invent', args: { ...invent('scout').args, actor: 'mallory' } }), /invalid-request/);
assert.throws(() => b.request(human, { verb: 'definition.remove', args: { revision: revision(), name: 'scout' } }), /invalid-request/);
assert.deepEqual(readFileSync(file), before);
// The human's invent commits, as the human, with a receipt that carries no role text.
const receipt = b.request(human, invent('scout'));
assert.deepEqual({ ...receipt, revision: 0, transaction: 0 }, { verb: 'invent', organization: 'demo', definition: 'scout', rev: 1, revision: 0, transaction: 0 });
assert.equal(JSON.parse(readFileSync(file, 'utf8')).definitions.scout.instructions, SCOUT.instructions);
const last = JSON.parse(readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').at(-1));
assert.equal(last.actor, 'jason');
assert.equal(last.state, 'committed');
// A writer refusal passes by its code.
assert.throws(() => b.request(human, invent('scout')), /definition-exists/);
// The bus refuses a request that carries the held token, and the writer refuses a template whose
// contract carries it (the import request itself holds no token). Neither writes.
const written = readFileSync(file);
assert.throws(() => b.request(human, { verb: 'definition.invent', args: { revision: revision(), name: 'leak', definition: { ...SCOUT, instructions: `Use ${TOKEN}.\n` } } }),
(e) => e.code === 'credential-leak' && !e.message.includes(TOKEN));
appendFileSync(join(rolesDir, 'coder.md'), `\nUse ${TOKEN} for the forge.\n`);
assert.throws(() => b.request(human, { verb: 'definition.import', args: { revision: revision(), template: 'coder' } }),
(e) => e.code === 'invalid-request' && !e.message.includes(TOKEN) && !String(e.stack).includes(TOKEN));
assert.deepEqual(readFileSync(file), written);
for (const path of [...files(data), ...files(join(root, 'businesses'))]) assert.ok(!readFileSync(path).includes(TOKEN), path);
});
+35 -33
View File
@@ -6,6 +6,7 @@
// keys are real local ssh-keygen pairs in the scratch store, and no ssh connection is made. Decisions a
// test needs outside the human's yes go in by SQL as fixtures.
import test from 'node:test';
import { hireServiceFixture, fixtureConsole, approveFixtureFire } from './hire-fixtures.mjs';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
@@ -156,7 +157,7 @@ async function setup(t) {
return { revision: REV };
};
const make = () =>
new Broker({
new Broker({hires:hireServiceFixture,
store,
businesses: { demo: structuredClone(doc) },
secretCheck: (value) => credentials.assertClean(value),
@@ -184,7 +185,7 @@ async function setup(t) {
store
.all("SELECT actor_role, subject, body FROM events WHERE business='demo' AND kind='action.allowed' ORDER BY seq")
.map((e) => ({ ...e, body: JSON.parse(e.body) }))
.filter((e) => e.body.action === action);
.filter((e) => e.body.action === action && e.body.operation !== 'decision.raise');
// The bus's step G messages to the human: no decision, so no grant notice.
const inbox = () =>
store.all("SELECT body FROM messages WHERE business='demo' AND from_role='_bus' AND to_role='human' AND decision IS NULL ORDER BY rowid").map((r) => r.body);
@@ -232,7 +233,8 @@ async function setup(t) {
adminSha256: adminSha256(grantApi.adminMeta('demo') ?? meta),
},
});
const fire = (name) => call('chief', 'instance.fire', { name, reason: 'the work is done' });
const hireConsole=fixtureConsole(broker);
const fire = name => approveFixtureFire(broker,hireConsole,name);
// A delete as the launcher's pass runs it: the human's request, the take, then lifecycleDelete. A
// refused delete closes the request with its code, as the launcher does.
const remove = (instance) => {
@@ -304,7 +306,7 @@ function allRevoked(f, hosts = ['web1', 'web2']) {
test('row 281 T2: a fire with host grants on two hosts and a Gitea grant answers with a job, then revokes all three', async (t) => {
const f = await setup(t);
await threeGrants(f);
const out = f.fire('coder');
const out = await f.fire('coder');
assert.equal(out.done, false);
assert.match(out.job, UUID);
assert.deepEqual(out.revoking, ['host:web1', 'host:web2', 'gitea']);
@@ -313,7 +315,7 @@ test('row 281 T2: a fire with host grants on two hosts and a Gitea grant answers
assert.equal(more.length, 0);
assert.equal(revoking.body.job, out.job);
assert.deepEqual(revoking.body.grants, out.revoking);
assert.equal(revoking.actor_role, 'chief');
assert.equal(revoking.actor_role, null);
assert.equal(revoking.subject, 'coder');
await f.broker.grantsIdle();
// Each host as hosts.revoke records it, with fire: true, line first so the close says removed.
@@ -340,7 +342,7 @@ test('row 281 T2: a fire with host grants on two hosts and a Gitea grant answers
assert.deepEqual(done.body.grants.map((x) => x.grant), ['host:web1', 'host:web2', 'gitea']);
assert.deepEqual(done.body.grantsLeft, []);
assert.match(done.body.transaction, /^t-/);
assert.equal(done.actor_role, 'chief');
assert.equal(done.actor_role, null);
assert.deepEqual(f.org.log.filter(([op]) => op === 'instance.fire'), [['instance.fire', 'coder']]);
assert.ok(!f.doc.launch.instances.includes('coder'));
// No step G message for a job that completed.
@@ -480,7 +482,7 @@ test('row 281 T4: host B unreachable in a fire: the job refuses, A stays revoked
const f = await setup(t);
await threeGrants(f);
f.failing.add('web2');
const out = f.fire('coder');
const out = await f.fire('coder');
await f.broker.grantsIdle();
const [refused, ...more] = f.grantEvents().filter((e) => e.action === 'grant.revoke.refused');
assert.equal(more.length, 0);
@@ -510,7 +512,7 @@ test('row 281 T4: host B unreachable in a fire: the job refuses, A stays revoked
assert.equal(f.state('coder', 'gitea'), 'valid');
// B back: the repeat revokes only B and Gitea.
f.failing.delete('web2');
const repeat = f.fire('coder');
const repeat = await f.fire('coder');
assert.deepEqual(repeat.revoking, ['host:web2', 'gitea']);
await f.broker.grantsIdle();
const [done] = f.allowed('fire.done');
@@ -529,7 +531,7 @@ test('row 281 T4b (Q1): a line a manual hosts.revoke left on C is removed by the
const tag3 = f.tag('coder', 'web3');
assert.equal(manual.hostLineLeft, tag3);
f.failing.delete('web3');
const out = f.fire('coder');
const out = await f.fire('coder');
assert.deepEqual(out.revoking, ['host:web1', 'line:web3']);
await f.broker.grantsIdle();
assert.equal(f.lines.web3.has(tag3), false);
@@ -552,7 +554,7 @@ test('row 281 T4b (Q1): with C unreachable, the fire completes and names the lin
f.failing.add('web3');
await f.call('human', 'hosts.revoke', { agent: 'coder', host: 'web3', reason: 'web3 is down' });
const tag3 = f.tag('coder', 'web3');
const out = f.fire('coder');
const out = await f.fire('coder');
assert.deepEqual(out.revoking, ['host:web1', 'line:web3']);
await f.broker.grantsIdle();
assert.equal(f.grantEvents().filter((e) => e.action === 'grant.revoke.refused').length, 0);
@@ -568,7 +570,7 @@ test("row 281 T5: Gitea's admin token can't be checked: the job refuses with the
const f = await setup(t);
await f.approveGitea('coder');
const off = f.gitea.rule((e) => e.method === 'GET' && e.path === '/api/v1/version', { status: 500, json: {} });
const out = f.fire('coder');
const out = await f.fire('coder');
assert.deepEqual(out.revoking, ['gitea']);
await f.broker.grantsIdle();
off();
@@ -597,7 +599,7 @@ test("row 281 T5b (N1): a failed token DELETE refuses gitea-left; the repeat ret
const helperDeletes = () => f.gitea.requests.filter(tokenDelete('demo-helper')).length;
const before = helperDeletes();
const offCoder = f.gitea.rule(tokenDelete('demo-coder'), { status: 500, json: {} });
const out = f.fire('coder');
const out = await f.fire('coder');
await f.broker.grantsIdle();
const [refused] = f.grantEvents().filter((e) => e.action === 'grant.revoke.refused');
assert.equal(refused.grant, 'gitea');
@@ -608,7 +610,7 @@ test("row 281 T5b (N1): a failed token DELETE refuses gitea-left; the repeat ret
assert.equal(f.allowed('fire.done').length, 0);
// Gitea back for coder only. The helper's DELETE still fails, so a business-wide sweep would show.
offCoder();
const repeat = f.fire('coder');
const repeat = await f.fire('coder');
assert.deepEqual(repeat.revoking, ['gitea']);
await f.broker.grantsIdle();
const [done] = f.allowed('fire.done');
@@ -627,7 +629,7 @@ test("row 281 T5b (N1): a failed token DELETE refuses gitea-left; the repeat ret
test('row 281 T6 (sage-2574): a host decision approved before the fire and run after it ends revoked, with nothing applied', async (t) => {
const f = await setup(t);
const id = f.fixtureHost('coder', 'web1');
const out = f.fire('coder');
const out = await f.fire('coder');
assert.deepEqual(out.revoking, ['host:web1']);
await f.broker.grantsIdle();
const [r] = f.revoked('coder', (e) => e.host === 'web1');
@@ -644,7 +646,7 @@ test('row 281 T6 (sage-2574): a host decision approved before the fire and run a
test('row 281 T6b (sage-2574): a Gitea decision approved before the fire and run after it ends revoked before any Gitea write', async (t) => {
const f = await setup(t);
const id = f.fixtureGitea('coder');
const out = f.fire('coder');
const out = await f.fire('coder');
assert.deepEqual(out.revoking, ['gitea']);
await f.broker.grantsIdle();
const [r] = f.revoked('coder', (e) => e.method === 'gitea');
@@ -689,7 +691,7 @@ test('row 281 T6c: a host executor in flight aborts and ends revoked; a Gitea ex
const gd = await f.raiseGitea('coder');
f.call('human', 'decision.resolve', { id: gd.id, choice: 'yes' });
await until(() => reached);
const out = f.fire('coder');
const out = await f.fire('coder');
assert.deepEqual(out.revoking, ['host:web1', 'gitea']);
await until(() => f.ended(host.id));
assert.equal(f.ended(host.id).code, 'revoked');
@@ -714,7 +716,7 @@ test('row 281 T6d (B1): undecided host and Gitea decisions are in the set, and a
const f = await setup(t);
const host = await f.raiseHost('coder', 'web1');
const gd = await f.raiseGitea('coder');
const out = f.fire('coder');
const out = await f.fire('coder');
assert.deepEqual(out.revoking, ['host:web1', 'gitea']);
await f.broker.grantsIdle();
assert.equal(f.allowed('fire.done').length, 1);
@@ -736,14 +738,14 @@ test('row 281 T6e (B1): a grant raise refuses instance-fired while firing and af
await f.approveHost('coder', 'web1');
// Firing with no job: step B refused, so fire.stopped stands with no fire.done.
f.org.fireError = Object.assign(new Error('refused'), { code: 'credential-revoke-failed' });
f.fire('coder');
await f.fire('coder');
await f.broker.grantsIdle();
assert.equal(f.allowed('fire.failed').length, 1);
await rejects(() => f.raiseHost('coder', 'web2'), 'instance-fired');
await rejects(() => f.raiseGitea('coder'), 'instance-fired');
// After fire.done: delisted.
f.org.fireError = null;
assert.match(f.fire('coder').transaction, /^t-/);
assert.match((await f.fire('coder')).transaction, /^t-/);
await rejects(() => f.raiseHost('coder', 'web2'), 'instance-fired');
await rejects(() => f.raiseGitea('coder'), 'instance-fired');
// A rehire lists the name again.
@@ -761,7 +763,7 @@ test('row 281 T6e (B1): a grant raise refuses instance-fired while firing and af
test('row 281 T7: an instance with no grant fires as today, synchronously, with the same events', async (t) => {
const f = await setup(t);
const out = f.fire('helper');
const out = await f.fire('helper');
assert.deepEqual(Object.keys(out).sort(), ['cancelled', 'request', 'revision', 'runs', 'taken', 'transaction']);
assert.match(out.transaction, /^t-/);
// Recorded before the verb answered: no job.
@@ -781,9 +783,9 @@ test('row 281 T9 (B3): one step G per agent; the manual verbs refuse while it ru
await f.approveHost('coder', 'web1');
await f.approveGitea('coder');
f.held.add('web1:remove');
f.fire('coder');
await f.fire('coder');
await until(() => f.sent.includes(`remove web1 ${f.tag('coder', 'web1')}`));
assert.equal(code(() => f.fire('coder')), 'fire-in-progress');
await assert.rejects(f.fire('coder'),{code:'fire-in-progress'});
assert.equal(f.remove('coder').code, 'grants-revoking');
assert.equal(f.allowed('grants.revoking').length, 0);
assert.equal(f.allowed('fire.revoking').length, 1);
@@ -820,7 +822,7 @@ test('row 281 T10 (B2): the fire and the delete answer while their jobs are held
// The verbs answer before the event loop turns, so before any part of a job has run.
let turned = false;
setImmediate(() => (turned = true));
const fired = f.fire('coder');
const fired = await f.fire('coder');
const deleted = f.remove('ops');
assert.equal(turned, false);
assert.equal(fired.done, false);
@@ -844,7 +846,7 @@ test("row 281 T11 (C1): a fire's job completes after the chief's run ends and pr
const f = await setup(t);
await f.approveHost('coder', 'web1');
f.held.add('web1:remove');
const out = f.fire('coder');
const out = await f.fire('coder');
await until(() => f.sent.includes(`remove web1 ${f.tag('coder', 'web1')}`));
f.broker.endLaunch({ business: 'demo', run: 'chief-run', reason: 'test' });
f.sw.state = { version: 1, halted: true, generation: GENERATION };
@@ -855,7 +857,7 @@ test("row 281 T11 (C1): a fire's job completes after the chief's run ends and pr
assert.match(done.body.transaction, /^t-/);
const [used, ...more] = f.allowed('role.fire');
assert.equal(more.length, 0);
assert.equal(used.actor_role, 'chief');
assert.equal(used.actor_role, null);
assert.equal(used.body.transaction, done.body.transaction);
assert.ok(!f.doc.launch.instances.includes('coder'));
assert.equal(f.allowed('fire.failed').length, 0);
@@ -867,12 +869,12 @@ test("row 281 T12 (C1, N7): a step B refusal in the job is fire.failed, and the
await f.approveHost('coder', 'web1');
// N7: the writer's own credential-revoke-failed, which step G's key revokes can't clear.
f.org.fireError = Object.assign(new Error('refused'), { code: 'credential-revoke-failed' });
const out = f.fire('coder');
const out = await f.fire('coder');
await f.broker.grantsIdle();
const [failed, ...more] = f.allowed('fire.failed');
assert.equal(more.length, 0);
assert.deepEqual([failed.body.job, failed.body.step, failed.body.code], [out.job, 'B', 'credential-revoke-failed']);
assert.equal(failed.actor_role, 'chief');
assert.equal(failed.actor_role, null);
const [message] = f.inbox();
for (const s of [out.job, 'host:web1', 'step B', 'credential-revoke-failed']) assert.ok(message.includes(s), s);
assert.equal(f.grantEvents().filter((e) => e.action === 'grant.revoke.refused').length, 0);
@@ -882,7 +884,7 @@ test("row 281 T12 (C1, N7): a step B refusal in the job is fire.failed, and the
// The grant was revoked before step B.
assert.equal(f.hostRow('coder', 'web1'), undefined);
f.org.fireError = null;
const repeat = f.fire('coder');
const repeat = await f.fire('coder');
assert.equal(repeat.job, undefined);
assert.match(repeat.transaction, /^t-/);
assert.equal(f.allowed('fire.done').length, 1);
@@ -900,7 +902,7 @@ test('row 281 T13 (N4): a restart records each held job interrupted once, with o
assert.equal(f.allowed('grants.revoked.done').length, 1);
f.held.add('web1:remove');
f.held.add('web2:remove');
const fired = f.fire('coder');
const fired = await f.fire('coder');
assert.equal(f.remove('ops').code, 'grants-revoking');
const deleteJob = f.allowed('grants.revoking').at(-1).body.job;
await until(() => f.sent.includes(`remove web1 ${f.tag('coder', 'web1')}`) && f.sent.includes(`remove web2 ${f.tag('ops', 'web2')}`));
@@ -930,7 +932,7 @@ test("row 281 T14 (N1, sage-2653): a host removed under an undecided decision st
const d = await f.raiseHost('coder', 'web1');
// N1: hosts.remove doesn't check open decisions, so the decision outlives its host.
assert.deepEqual(await f.call('human', 'hosts.remove', { host: 'web1' }), { host: 'web1', removed: true });
const out = f.fire('coder');
const out = await f.fire('coder');
assert.match(out.job, UUID);
assert.deepEqual(out.revoking, ['host:web1']);
await f.broker.grantsIdle();
@@ -946,7 +948,7 @@ test("row 281 T14 (N1, sage-2653): a host removed under an undecided decision st
f.call('human', 'decision.resolve', { id: d.id, choice: 'no' });
await f.broker.grantsIdle();
assert.equal(f.ended(d.id), undefined);
const repeat = f.fire('coder');
const repeat = await f.fire('coder');
assert.equal(repeat.job, undefined);
assert.match(repeat.transaction, /^t-/);
assert.equal(f.allowed('fire.done').length, 1);
@@ -961,7 +963,7 @@ test("row 239 T4: a refusal after the writer's revoke is named -revoked, in the
const f = await setup(t);
await f.approveHost('coder', 'web1');
f.org.fireError = Object.assign(new Error('refused'), { code: 'revision-conflict', revoked: true });
const out = f.fire('coder');
const out = await f.fire('coder');
await f.broker.grantsIdle();
const [failed] = f.allowed('fire.failed');
assert.deepEqual([failed.body.job, failed.body.step, failed.body.code], [out.job, 'B', 'revision-conflict-revoked']);
@@ -987,7 +989,7 @@ test("row 239 T4b: an unmarked step B refusal keeps its code, and the notice say
const f = await setup(t);
await f.approveHost('coder', 'web1');
f.org.fireError = Object.assign(new Error('refused'), { code: 'revision-conflict' });
const out = f.fire('coder');
const out = await f.fire('coder');
await f.broker.grantsIdle();
const [failed] = f.allowed('fire.failed');
assert.deepEqual([failed.body.job, failed.body.code], [out.job, 'revision-conflict']);
+207
View File
@@ -0,0 +1,207 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Broker } from '../src/broker.mjs';
import { Store } from '../src/store.mjs';
test('A4 direct instance.hire and instance.fire refuse and cannot write', t => {
const root = mkdtempSync(join(tmpdir(), 'a4-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
let writes = 0;
const b = new Broker({ store, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: ['role.hire','role.fire'], crossRole: [] } } }, launch: { by: 'chief', instances: [] } } }, organization: () => { writes++; } });
const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
b.request(chief, { verb: 'role.claim' });
for (const verb of ['instance.hire', 'instance.fire']) assert.throws(() => b.request(chief, { verb, args: { name: 'scout', definition: 'coder', reason: 'Need help' } }), { code: 'decision-required' });
assert.equal(writes, 0);
});
import { hireDecisions } from '../../organization/src/hire-decisions.mjs';
import { organizationWriter } from '../../organization/src/organization.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
import { readFileSync, mkdirSync } from 'node:fs';
const INLINE = { tools: ['read'], network: 'none', authority: { withinRole: [], crossRole: [] }, credentials: [], instructions: 'Read and report sk-test-NOT-A-SECRET-1234.' };
const SUBJECT={provider:'test',iss:'https://issuer.test',sub:'owner'};
function fixture(t) {
const root=mkdtempSync(join(tmpdir(),'a4-bus-')), rolesDir=rolesCopy(root);
const file=writeJson(join(root,'businesses/demo.json'),{...businessDoc(root,'demo'),plugins:{harness:['pi']}});
const storeRoot=join(root,'data');mkdirSync(storeRoot,{mode:0o700});
const writer=organizationWriter({dir:root,rolesDir}),store=new Store(storeRoot);
const service=hireDecisions({writer,rolesDir,dataRoot:storeRoot,configDir:root,assertClean:()=>{}});
const loaded=()=>{const doc=loadBusiness('demo',{dir:root,rolesDir});return {...doc,roles:Object.fromEntries(Object.entries(doc.roles).map(([n,e])=>[n,{...doc.definitions[e.definition],...e}]))};};
let writes=0;
const b=new Broker({store,businesses:{demo:loaded()},hires:{...service,apply:async(...args)=>{writes++;const r=await service.apply(...args);b.reloadBusiness('demo',loaded());return r;}}});
const owner=b.bindConsole({business:'demo',human:'jason',subject:SUBJECT});
const member=rights=>b.bindHireConsole({business:'demo',actor:'member',subject:{...SUBJECT,sub:'member'},rights});
const caps={owner,chief:b.bindLaunch({business:'demo',role:'chief',run:'chief-run',harness:'pi'}),coder:b.bindLaunch({business:'demo',role:'coder',run:'coder-run',harness:'pi'})};
b.request(caps.chief,{verb:'role.claim'});b.request(caps.coder,{verb:'role.claim'});
const call=(cap,verb,args={})=>b.request(cap,{verb,args});
const request=extra=>({op:'hire',level:'org',name:'scout',role:{inline:INLINE},route:{harness:'pi',model:'claude-opus-5-5'},reason:'Need help',...extra});
const raise=extra=>call(caps.chief,'hire.raise',request(extra));
t.after(()=>{store.close();rmSync(root,{recursive:true,force:true});});
const reloadChief=authority=>{const doc=loaded();doc.roles.chief.authority=authority;b.reloadBusiness('demo',doc);};
return {root,rolesDir,b,store,file,writer,service,owner,member,caps,call,request,raise,reloadChief,writes:()=>writes};
}
test('A4 only the chief raises; forged flagged lines refuse without a record',async t=>{
const {call,caps,request,store}=fixture(t);
await assert.rejects(call(caps.coder,'hire.raise',request()),{code:'role-not-chief'});
await assert.rejects(call(caps.chief,'hire.raise',request({flagged:[]})),{code:'invalid-request'});
assert.equal(store.get('SELECT count(*) n FROM decisions').n,0);
});
test('A4 approve is digest bound and consumed once; generic decide never resolves hire, fire or change',async t=>{
const {raise,call,owner,writes,store,file,b,caps}=fixture(t);
const d=await raise();const p=await call(owner,'hire.preview.console',{id:d.id});
assert.ok(!JSON.stringify(p).includes('sk-test-NOT-A-SECRET-1234'));
await assert.rejects(call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:'0'.repeat(64)}),{code:'decision-stale'});
for(const action of ['role.hire','role.fire','role.change']) {
if(action!==d.action)store.run('INSERT INTO decisions(id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)',action,'2026-01-01T00:00:00.000Z','demo','chief','chief-run','gated',action,'human','Question?',JSON.stringify([{key:'yes',text:'Yes'},{key:'no',text:'No'}]),'no',0);
assert.throws(()=>call(owner,'decision.resolve.console',{id:action===d.action?d.id:action,choice:'yes',digest:p.digest}),{code:'hire-resolution-required'});
const human=b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
assert.throws(()=>call(human,'decision.resolve',{id:action===d.action?d.id:action,choice:'yes'}),{code:'hire-resolution-required'});
}
const before=readFileSync(file);
await call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:p.digest});
assert.equal(writes(),1);assert.notDeepEqual(readFileSync(file),before);
await assert.rejects(call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:p.digest}),{code:'decision-closed'});
assert.equal(writes(),1);
for(const row of [...store.all('SELECT * FROM decisions'),...store.all('SELECT * FROM events')])assert.ok(!JSON.stringify(row).includes('sk-test-NOT-A-SECRET-1234'));
});
test('A4 denied and expired decisions leave organization bytes untouched',async t=>{
const {raise,call,owner,file,writes}=fixture(t);
const before=readFileSync(file),d=await raise();
await call(owner,'hire.resolve.console',{id:d.id,choice:'no',digest:d.hire.digest});
const e=await raise(); const original=Date.now;
try{Date.now=()=>original()+8*86400000;await assert.rejects(call(owner,'hire.resolve.console',{id:e.id,choice:'yes',digest:e.hire.digest}),{code:'decision-expired'});}finally{Date.now=original;}
assert.deepEqual(readFileSync(file),before);assert.equal(writes(),0);
});
test('A4 members with org.hire refuse flagged or credential grants; decide alone refuses all hires',async t=>{
const {raise,call,member}=fixture(t);
const plain=await raise();
await assert.rejects(call(member(['decide']),'hire.resolve.console',{id:plain.id,choice:'yes',digest:plain.hire.digest}),{code:'right-required'});
const flagged=await raise({role:{inline:{...INLINE,tools:['read','bash']}}});
const credential=await raise({role:{inline:{...INLINE,credentials:[{service:'gitea',scopes:['read:repository']}]}}});
for(const d of [flagged,credential])await assert.rejects(call(member(['org.hire']),'hire.resolve.console',{id:d.id,choice:'yes',digest:d.hire.digest}),{code:'hire-owner-required'});
await call(member(['org.hire']),'hire.resolve.console',{id:plain.id,choice:'yes',digest:plain.hire.digest});
});
test('A4 human Console start raises and resolves one recorded decision with computed lines',async t=>{
const {call,owner,request,store,writes}=fixture(t);
const r=await call(owner,'hire.start.console',request({role:{inline:{...INLINE,tools:['read','bash']}}}));
assert.equal(r.op,'resolved');assert.equal(writes(),1);
const event=store.all("SELECT body FROM events WHERE json_extract(body,'$.operation')='decision.raise'").map(e=>JSON.parse(e.body))[0];
assert.deepEqual(event.hire.flagged,[{field:'tools',value:'bash',reasons:['above-chief']}]);
assert.equal(store.get('SELECT raised_by_role FROM decisions WHERE id=?',r.id).raised_by_role,'human');
});
test('A4 a stale organization digest never reaches the writer',async t=>{
const {raise,call,owner,writer,file,writes}=fixture(t);
const d=await raise(),old=writer.read('demo');
writeJson(file,{...old.document,vars:{...old.document.vars,'tracker.pollSeconds':61}});
await assert.rejects(call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:d.hire.digest}),{code:'decision-stale'});
assert.equal(writes(),0);
});
test('A4 approved credential role refuses launch until a binding is present',async t=>{
const {raise,call,owner,b,caps,writer,file,root,rolesDir}=fixture(t);
const d=await raise({role:{inline:{...INLINE,credentials:[{service:'gitea',scopes:['read:repository']}]}}});
await call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:d.hire.digest});
assert.throws(()=>b.request(b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true}),{verb:'instance.launch',args:{instance:'scout'}}),{code:'credential-unbound'});
// Binding implementation is outside A4. The existing file shape supplies its metadata here.
const doc=writer.read('demo').document;
doc.roles.scout.credentials={gitea:{store:true,rotateBy:'2099-01-01'}};
writeJson(file,doc);
const loaded=loadBusiness('demo',{dir:root,rolesDir});
b.reloadBusiness('demo',{...loaded,roles:Object.fromEntries(Object.entries(loaded.roles).map(([n,e])=>[n,{...loaded.definitions[e.definition],...e}]))});
assert.doesNotThrow(()=>b.request(b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true}),{verb:'instance.launch',args:{instance:'scout'}}));
});
test('A4 refusals and records never carry invalid role text',async t=>{
const {raise,store}=fixture(t);
await assert.rejects(raise({role:{inline:{...INLINE,authority:{withinRole:['sk-test-NOT-A-SECRET-1234'],crossRole:[]}}}}),e=>{
assert.ok(!JSON.stringify(e).includes('sk-test-NOT-A-SECRET-1234'));return true;
});
assert.ok(!JSON.stringify(store.all('SELECT * FROM events')).includes('sk-test-NOT-A-SECRET-1234'));
});
test('A4 a human may mark a hire seen, and the chief may withdraw its own proposal',async t=>{
const {raise,call,b,caps,writes}=fixture(t),d=await raise();
const human=b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
assert.equal(call(human,'decision.seen',{id:d.id}).op,'seen');
assert.equal(call(caps.chief,'decision.withdraw',{id:d.id}).op,'withdrawn');
assert.equal(writes(),0);
});
test('A4 Console previews and consumes only the exact narrowed grants and route',async t=>{
const {raise,call,owner,writer,store}=fixture(t);
const d=await raise({role:{inline:{...INLINE,tools:['read','bash'],credentials:[{service:'gitea',scopes:['read:repository']}]}}});
const adjust={role:{tools:['read']},credentials:[],route:{harness:'pi',model:'claude-sonnet-5-5'}};
const p=await call(owner,'hire.preview.console',{id:d.id,adjust});
assert.notEqual(p.digest,d.hire.digest);assert.deepEqual(p.flagged,[]);assert.deepEqual(p.credentialLines,[]);
await assert.rejects(call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:p.digest}),{code:'decision-stale'});
await call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:p.digest,adjust});
const doc=writer.read('demo').document,entry=doc.definitions[doc.roles.scout.definition.slice(4)];
assert.deepEqual(entry.role.tools,['read']);assert.deepEqual(entry.role.credentials,[]);
const consume=JSON.parse(store.get("SELECT body FROM events WHERE json_extract(body,'$.operation')='hire.consume'").body);
assert.equal(consume.hire.digest,p.digest);assert.deepEqual(consume.hire.credentialLines,[]);
});
test('A4 Console lists template hashes without role text, under org.hire',async t=>{
const {call,owner,member}=fixture(t);
const list=await call(owner,'hire.templates.console');
assert.ok(list.templates.some(x=>x.name==='coder'&&/^[a-f0-9]{64}$/.test(x.sha256)));
assert.ok(list.templates.every(x=>Object.keys(x).sort().join(',')==='name,sha256'));
assert.throws(()=>call(member(['decide']),'hire.templates.console'),{code:'right-required'});
});
test('A4 chief proposals require current role.hire for hire/change and role.fire for fire',async t=>{
const f=fixture(t);
f.reloadChief({withinRole:['message.send'],crossRole:[]});
for(const op of ['hire','change','fire'])for(const verb of ['hire.raise','decision.raise']) {
const hire=f.request({op});
await assert.rejects(f.call(f.caps.chief,verb,verb==='decision.raise'?{action:`role.${op}`,hire}:hire),{code:'not-authorized'});
}
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n,0);
f.reloadChief({withinRole:['role.hire'],crossRole:[]});
assert.ok((await f.raise()).id);
assert.ok((await f.raise({op:'change',name:'coder'})).id);
const fire={op:'fire',level:'org',name:'coder',reason:'Finished'};
await assert.rejects(f.call(f.caps.chief,'hire.raise',fire),{code:'not-authorized'});
f.reloadChief({withinRole:['role.fire'],crossRole:[]});
assert.ok((await f.call(f.caps.chief,'hire.raise',fire)).id);
await assert.rejects(f.raise(),{code:'not-authorized'});
});
test('A4 chief authority is checked again after asynchronous proposal preparation',async t=>{
const f=fixture(t),pending=f.raise();
f.reloadChief({withinRole:['message.send'],crossRole:[]});
await assert.rejects(pending,{code:'not-authorized'});
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n,0);
});
test('A4 a member start needing owner approval refuses before saving a decision',async t=>{
const f=fixture(t),cap=f.member(['org.hire']),before=readFileSync(f.file);
for(const inline of [{...INLINE,tools:['read','bash']},{...INLINE,credentials:[{service:'gitea',scopes:['read:repository']}]}])
await assert.rejects(f.call(cap,'hire.start.console',f.request({role:{inline}})),{code:'hire-owner-required'});
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n,0);
assert.deepEqual(readFileSync(f.file),before);
assert.equal((await f.call(cap,'hire.start.console',f.request())).op,'resolved');
});
test('A4 inbox and decision reads expire unopened hires once without changing configuration',async t=>{
const f=fixture(t),d=await f.raise({role:{inline:{...INLINE,tools:['read','bash']}}}),before=readFileSync(f.file);
const human=f.b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
const original=Date.now;
try {
Date.now=()=>original()+8*86400000;
await assert.rejects(f.call(f.member(['org.hire']),'hire.preview.console',{id:d.id}),{code:'decision-expired'});
assert.deepEqual(f.call(human,'inbox'),[]);
const recent=f.call(human,'decisions',{list:'recent'});
assert.equal(recent[0].closed.by,'expiry');
const another=await f.raise();
Date.now=()=>original()+16*86400000;
assert.deepEqual(f.call(human,'inbox'),[]);
f.call(human,'decisions',{list:'recent'});
assert.equal(f.store.get("SELECT count(*) n FROM decision_events WHERE decision=? AND by='expiry'",another.id).n,1);
} finally {Date.now=original;}
assert.deepEqual(readFileSync(f.file),before);assert.equal(f.writes(),0);
});
+14
View File
@@ -0,0 +1,14 @@
// Trusted proposal service stand-in for executor-only tests. Authorization and real writers are
// covered by hire-decisions.test.mjs; these tests isolate the existing grant/fire executors.
export const hireServiceFixture = {
prepare: (_business,args,{id,expiresAt}) => ({id,op:args.op,level:args.level,name:args.name,revision:'b'.repeat(64),flagged:[],credentialLines:[],digest:'a'.repeat(64),expiresAt}),
check: () => {},
};
export const fixtureConsole = (broker,business='demo') => broker.bindConsole({business,human:'jason',subject:{provider:'test',iss:'https://issuer.test',sub:'owner'}});
export const approveFixtureFire = async (broker,cap,name) => {const {id,op,choice,...result}=await broker.request(cap,{verb:'hire.start.console',args:{op:'fire',level:'org',name,reason:'The work is done'}});if(result.done===false)delete result.transaction;return result;};
export const ownerConsole = (broker,business='demo') => broker.bindConsole({business,human:'jason',subject:{provider:'test',iss:'https://issuer.test',sub:'owner'}});
export async function approveTemplateHire(broker,rolesDir,{business='demo',name,definition,reason='Fixture hire',model='claude-opus-5-5',...extra}) {
const {readTemplate}=await import('../../organization/src/definitions.mjs');
const source=readTemplate(rolesDir,definition);
return broker.request(ownerConsole(broker,business),{verb:'hire.start.console',args:{op:'hire',level:'org',name,role:{template:definition,sha256:source.sha256},route:{harness:'pi',model},reason,...extra}});
}
@@ -6,6 +6,7 @@
// use fixtures: the evidence and the gitea_grants row go in by SQL, as gitea-grant.test.mjs does for
// decisions. Tokens are made up or the fake's, and only ever compared.
import test from 'node:test';
import { hireServiceFixture, fixtureConsole, approveFixtureFire } from './hire-fixtures.mjs';
import assert from 'node:assert/strict';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
@@ -68,7 +69,7 @@ function world(t, options) {
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const business = makeBusiness({ ...options, root });
const held = new Set();
const broker = new Broker({ store, businesses: { acme: business }, giteaKeys: { held: (b, a) => held.has(b + '/' + a) } });
const broker = new Broker({hires:hireServiceFixture, store, businesses: { acme: business }, giteaKeys: { held: (b, a) => held.has(b + '/' + a) } });
return { store, business, broker, held, choose: () => broker.libraryFetchCredential('acme') };
}
@@ -138,7 +139,7 @@ async function verbWorld(t, opts = {}) {
}
return { revision: REV };
};
broker = new Broker({
broker = new Broker({hires:hireServiceFixture,
store, businesses: { acme: structuredClone(doc) }, secretCheck: (value) => credentials.assertClean(value), organization,
processing: { read: () => SWITCH }, giteaGrant: grantApi, giteaKeys: giteaKeys({ credentials, storeRoot }),
});
@@ -174,7 +175,7 @@ async function verbWorld(t, opts = {}) {
assert.ok(events().some((e) => e.action === 'grant.revoked' && e.agent === 'chief'));
};
const fire = async () => {
await broker.request(boss, { verb: 'instance.fire', args: { name: 'chief', reason: 'the work is done' } });
await approveFixtureFire(broker,fixtureConsole(broker,'acme'),'chief');
await until(() => !doc.launch.instances.includes('chief'));
await broker.grantsIdle();
assert.equal(row(), undefined, 'the fire\'s step G deleted the row (row 281)');
@@ -196,7 +197,7 @@ test('C2-03b T6: the slot before any chief evidence, then the listed chief\'s li
assert.deepEqual(w.choose(), { kind: 'slot' }, 'a seat grant is not the chief\'s: the chief entry keeps the fallback');
grant(w.store, 'chief', { user: 'chief-bot' });
assert.deepEqual(w.choose(), { kind: 'chief', agent: 'chief', user: 'chief-bot' });
assert.deepEqual(new Broker({ store: w.store, businesses: { acme: w.business } }).libraryFetchCredential('acme'), REFUSED,
assert.deepEqual(new Broker({hires:hireServiceFixture, store: w.store, businesses: { acme: w.business } }).libraryFetchCredential('acme'), REFUSED,
'a bus with no grant store refuses rather than guess');
assert.throws(() => w.broker.libraryFetchCredential('other'), { code: 'unknown-business' });
assert.equal(LIBRARY_FETCH_FIX, 'add the chief of staff, or grant the library through C2-21');
+10 -8
View File
@@ -1,3 +1,4 @@
import { fixtureConsole, approveFixtureFire } from './hire-fixtures.mjs';
// Row 194 (#1692, brief item 8, row 199 D13, T20): fire and delete revoke the agent's credentials
// after the writer's checks and before the write. Fire revokes each service and keeps the store
// directory. Delete calls remove (sage-1593). A failed revoke stops the verb with
@@ -54,7 +55,8 @@ async function setup(t, { drop = [], seed = null } = {}) {
const chief = bus.broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
bus.broker.request(chief, { verb: 'role.claim' });
const ask = (verb, args) => bus.broker.request(human, { verb, args });
const fire = (name) => bus.broker.request(chief, { verb: 'instance.fire', args: { name, reason: 'the work is done' } });
const hireConsole=fixtureConsole(bus.broker);
const fire = name => approveFixtureFire(bus.broker,hireConsole,name);
const agentDir = (agent) => join(root, 'secrets', 'agents', 'demo', agent);
const plant = (agent, service) => {
mkdirSync(agentDir(agent), { recursive: true, mode: 0o700 });
@@ -85,12 +87,12 @@ test('T20 fire: a revoke that fails stops the fire with the file unchanged; one
// The revoke can't unlink the token, so it fails before the write: the file still lists coder.
const before = f.bytes();
chmodSync(f.agentDir('coder'), 0o500);
assert.equal(f.code(() => f.fire('coder')), 'credential-revoke-failed');
await assert.rejects(f.fire('coder'),{code:'credential-revoke-failed'});
chmodSync(f.agentDir('coder'), 0o700);
assert.equal(f.bytes(), before);
assert.ok(JSON.parse(before).launch.instances.includes('coder'));
const fired = f.fire('coder');
const fired = await f.fire('coder');
assert.equal(fired.delisted, true);
assert.deepEqual(fired.credentials, { revoked: ['gitea', 'vikunja'], outsideStore: ['vikunja'] });
assert.ok(!JSON.parse(f.bytes()).launch.instances.includes('coder'));
@@ -109,7 +111,7 @@ test('T20 fire: an instance already delisted still has its tokens revoked and th
doc.launch.instances = doc.launch.instances.filter((name) => name !== 'rev-2');
writeFileSync(f.file, JSON.stringify(doc, null, 2) + '\n', { mode: 0o600 });
const before = f.bytes();
const fired = f.fire('rev-2');
const fired = await f.fire('rev-2');
assert.equal(fired.delisted, false);
assert.equal(fired.transaction, null);
assert.deepEqual(fired.credentials, { revoked: ['gitea', 'vikunja'], outsideStore: [] });
@@ -122,10 +124,10 @@ test('T20 fire: with no entry in the running bus, a token on disk refuses and an
const f = await setup(t, { drop: ['rev-2'] });
f.plant('rev-2', 'gitea');
const before = f.bytes();
assert.equal(f.code(() => f.fire('rev-2')), 'credential-revoke-failed');
await assert.rejects(f.fire('rev-2'),{code:'credential-revoke-failed'});
assert.equal(f.bytes(), before);
rmSync(join(f.agentDir('rev-2'), 'gitea'));
const fired = f.fire('rev-2');
const fired = await f.fire('rev-2');
assert.equal(fired.delisted, true);
assert.deepEqual(fired.credentials, { revoked: ['gitea', 'vikunja'], outsideStore: [] });
});
@@ -198,11 +200,11 @@ test('row 281 T1 fire: a host.<id> or granted gitea file in the store refuses st
const f = await setup(t);
f.plant('coder', 'host.web1');
const before = f.bytes();
assert.equal(f.code(() => f.fire('coder')), 'credential-revoke-failed');
await assert.rejects(f.fire('coder'),{code:'credential-revoke-failed'});
assert.equal(f.bytes(), before);
assert.deepEqual(readdirSync(f.agentDir('coder')), ['host.web1']);
rmSync(join(f.agentDir('coder'), 'host.web1'));
assert.equal(f.fire('coder').delisted, true);
assert.equal((await f.fire('coder')).delisted, true);
});
// T1, the start: a host_grants record whose key file is gone (what a delete left before this row). The
@@ -1,3 +1,5 @@
import {readTemplate} from '../../organization/src/definitions.mjs';
import { approveTemplateHire, ownerConsole } from './hire-fixtures.mjs';
// #655 isolated STATIC fixture: actual managed fromSession caller + runtime/writer/controller.
import fs from 'node:fs';
import http from 'node:http';
@@ -25,10 +27,10 @@ export async function ownershipFixture(t,{launches=true,listed=true,keep=false,r
// Available before any acquired resource/setup can fail; no later f/launcher TDZ.
const launchLog=()=>{const file=launchLogFile(join(root,'data'),'acme');return fs.existsSync(file)?fs.readFileSync(file,'utf8').trim().split('\n').filter(Boolean).map(line=>JSON.parse(line)):[];};
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;});
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'];if(!listed)delete doc.roles.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\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 chiefFile=join(f.roles,'chief-of-staff.json');
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);
const state={keys:[sentinel],calls:[],refuseProbe,counts:{GET:0,PUT:0,PROBE:0},puts:[],failRemoval:false};
@@ -110,7 +112,7 @@ export async function ownershipFixture(t,{launches=true,listed=true,keep=false,r
const file=join(configDir,'businesses','acme.json');
const hold=(method='GET',ordinal=state.counts[method]+1)=>{let release;const promise=new Promise(r=>{release=r;});const gate={method,ordinal,seen:false,used:false,cancelled:false,promise,release};gates.push(gate);return {ordinal,seen:()=>gate.seen,entered:()=>waitFor(()=>gate.seen,'owned HTTP gate not reached',5000),release,cancel:()=>{gate.cancelled=true;release();}};};
const outcome=promise=>{const box={settled:false};box.promise=track(Promise.resolve(promise).then(value=>{box.settled=true;box.ok=true;box.value=value;return box;},error=>{box.settled=true;box.ok=false;box.code=error.code??'unexpected-error';return box;}));return box;};
const hire=(name='aide',extra={},cap=chief)=>{const called={value:false};const box=outcome(Promise.resolve().then(()=>{const operation=bus.broker.request(cap,{verb:'instance.hire',args:{name,definition:'aide',reason:'fixture ownership',...extra}});called.value=true;return operation;}));Object.defineProperty(box,'invoked',{get:()=>called.value});return box;};
const hire=(name='aide',extra={},cap=chief)=>{const called={value:false};const box=outcome(Promise.resolve().then(()=>{const operation=(async()=>{const definition=extra.definition??'aide';const d=await bus.broker.request(cap,{verb:'hire.raise',args:{op:'hire',level:'org',name,role:{template:definition,sha256:readTemplate(f.roles,definition).sha256},route:{harness:'pi',model:'claude-opus-5-5'},reason:'fixture ownership',...(extra.vars?{vars:extra.vars}:{})}});return bus.broker.request(ownerConsole(bus.broker,'acme'),{verb:'hire.resolve.console',args:{id:d.id,choice:'yes',digest:d.hire.digest}});})();called.value=true;return operation;}));Object.defineProperty(box,'invoked',{get:()=>called.value});return box;};
const record=(name='aide')=>bus.credentials.modelRecord('acme/'+name);
const snapshot=()=>{const journal=join(configDir,'businesses','.history','acme','changes.jsonl');return {membership:id(fs.readFileSync(file)),journal:fs.existsSync(journal)?id(fs.readFileSync(journal)):null,credentials:id(JSON.stringify(bus.credentials.status())),model:record()?id(JSON.stringify(record())):null,remote:id(JSON.stringify(state.keys)),calls:state.calls.length};};
return {root,f,state,trace,proxyReceipts,logs,launcher,bus,chief,record,hold,outcome,hire,snapshot,file,launchLog,expectedKeyId:()=>expectedKeyId,valueFree,
@@ -123,7 +125,7 @@ export async function ownershipFixture(t,{launches=true,listed=true,keep=false,r
assertModelAbsent:name=>{assert.equal(bus.credentials.modelRecord('acme/'+name)===null,true);assert.equal(fs.existsSync(join(configDir,'secrets','agents','acme',name,'model')),false);},
rotateBound:async()=>{const before=record();bus.credentials.writeModel('acme/aide',{...before,rotateBy:new Date(Date.now()-86400_000).toISOString().slice(0,10)});await waitFor(()=>record()?.retiring.some(r=>r.keyId===before.keyId),'original runtime sweep rotates bound generation');return before.keyId;},
explicitEnd:()=>bus.broker.request(chief,{verb:'role.revoke',args:{role:'aide',reason:'fixture explicit end'}}),
fire:()=>outcome(Promise.resolve().then(()=>bus.broker.request(chief,{verb:'instance.fire',args:{name:'aide',reason:'fixture explicit fire'}}))),
fire:()=>outcome(Promise.resolve().then(()=>bus.broker.request(ownerConsole(bus.broker,'acme'),{verb:'hire.start.console',args:{op:'fire',level:'org',name:'aide',reason:'fixture explicit fire'}}))),
runDirectories:()=>fs.existsSync(runsDir(f.dataRoot,'acme'))?fs.readdirSync(runsDir(f.dataRoot,'acme')).map(name=>join(runsDir(f.dataRoot,'acme'),name)):[],
sessions:()=>readSessions(f.dataRoot),send:body=>bus.broker.request(human,{verb:'message.send',args:{to:'aide',body,class:'REQUEST'}}),
reply:sent=>waitFor(()=>bus.broker.request(human,{verb:'message.receive'}).find(m=>m.in_reply_to===sent.id),'real managed reply observed'),
@@ -38,9 +38,8 @@ test('655 actual launch own preflight generation and bound consumer survive conc
for(const [label,options,args,code] of [
['retired name',{retired:true},{name:'alpha'},'organization-instance-retired'],
['listed duplicate vars',{}, {vars:{model:'claude-opus-5-5'}},'organization-instance-exists'],
['fired mismatched legal definition',{listed:false},{definition:'helper'},'organization-instance-exists'],
['fired matching definition with vars',{listed:false},{vars:{model:'claude-opus-5-5'}},'organization-instance-exists'],
['listed duplicate vars',{}, {vars:{model:'claude-opus-5-5'}},'invalid-request'],
['fired matching definition with vars',{listed:false},{vars:{model:'claude-opus-5-5'}},'invalid-request'],
])test('655 shared validation '+label+' has no credential effects',{timeout:30000},async t=>{
const f=await ownershipFixture(t,options),before=f.snapshot(),{name='aide',...extra}=args;await refused(f.hire(name,extra),code);
assert.deepEqual(f.snapshot(),before,'shared hire refusal precedes credential registration or proxy I/O');f.valueFree();
@@ -49,7 +48,7 @@ for(const [label,options,args,code] of [
for(const [label,options,extra,code] of [
['kept target',{keep:true},{},'instance-kept'],
['noHire definition',{noHire:true},{},'definition-not-hireable'],
['ineligible definition',{}, {definition:'security-reviewer'},'definition-not-hireable'],
['duplicate with another definition',{}, {definition:'security-reviewer'},'organization-instance-exists'],
['invalid vars',{}, {vars:{harness:'pi'}},'invalid-request'],
])test('655 existing Broker '+label+' refusal remains before model effects',{timeout:30000},async t=>{
const f=await ownershipFixture(t,options),before=f.snapshot();await refused(f.hire('aide',extra),code);assert.deepEqual(f.snapshot(),before);f.valueFree();
@@ -59,9 +58,9 @@ test('655 existing nonchief actor refusal remains before shared checks and model
const f=await ownershipFixture(t),cap=f.wrongActor(),before=f.snapshot();await refused(f.hire('aide',{},cap),'role-not-chief');assert.deepEqual(f.snapshot(),before);f.valueFree();
});
test('655 valid fired matching definition rehire publishes before queued acquisition',{timeout:30000},async t=>{
test('655 valid new hire publishes before queued acquisition',{timeout:30000},async t=>{
const f=await ownershipFixture(t,{listed:false}),put=f.hold('PUT'),h=f.hire();await put.entered();const p=f.preflight();assert.equal(f.listed('aide'),false);
put.release();await done(h);assert.equal(h.ok,true);assert.equal(typeof h.value.transaction,'string');await done(p);assert.equal(p.ok,true);assert.equal(f.listed('aide'),true);assert.equal(p.value.key===f.record().key,true);assert.equal(await f.healthy(p.value.key),200);f.valueFree();
put.release();await done(h);assert.equal(h.ok,true);assert.equal(typeof h.value.transaction,'string');await done(p);assert.equal(p.code,'unknown-role','no admission before membership publication');assert.equal(f.listed('aide'),true);const admitted=await done(f.preflight());assert.equal(admitted.ok,true);assert.equal(admitted.value.key===f.record().key,true);assert.equal(await f.healthy(admitted.value.key),200);f.valueFree();
});
test('655 preexisting admitted generation and retiring metadata survive duplicate failure',{skip:unavailable,timeout:30000},async t=>{
@@ -74,22 +73,22 @@ test('655 captured revision is checked after controller queue before generation
const f=await ownershipFixture(t),gate=f.hold('GET'),p=f.preflight('chief');await gate.entered();const h=f.hire('alpha');
// A following same-business operation proves H established its pending slot,
// rather than an arbitrary sleep before the external owner revision change.
await refused(f.hire('beta'),'organization-busy');f.bumpRevision();gate.release();await done(p);assert.equal(p.ok,true);await refused(h,'revision-conflict');
await refused(f.hire('beta'),'organization-busy');f.bumpRevision();gate.release();await done(p);assert.equal(p.ok,true);await refused(h,'decision-stale');
assert.equal(f.state.counts.PUT,1,'queued stale hire does not issue a generation');f.assertModelAbsent('alpha');assert.equal(f.listed('alpha'),false);f.valueFree();
});
test('655 genuine new generation revision failure cleans before queued acquisition receives a key',{timeout:30000},async t=>{
const f=await ownershipFixture(t,{listed:false}),put=f.hold('PUT'),h=f.hire();await put.entered();
const failedGeneration=f.state.puts[0].find(k=>!k.includes('-sentinel-'));assert.ok(failedGeneration,'real first issuance payload observed');
const p=f.preflight();f.bumpRevision();put.release();await refused(h,'revision-conflict');await done(p);
const valid=p.ok&&p.value.key!==failedGeneration&&f.record()?.key===p.value.key&&f.state.keys.includes(p.value.key)&&!f.state.keys.includes(failedGeneration);
assert.equal(valid,true,'queued acquisition receives a valid post-cleanup generation');assert.equal(f.listed('aide'),false);assert.equal(await f.healthy(p.value.key),200);f.valueFree();
const p=f.preflight();f.bumpRevision();put.release();await refused(h,'decision-stale');await done(p);
assert.equal(p.code,'unknown-role','an unpublished agent cannot acquire a model key');
assert.equal(f.state.keys.includes(failedGeneration),false,'failed generation was removed');f.assertModelAbsent('aide');assert.equal(f.listed('aide'),false);f.valueFree();
});
test('655 failed cleanup retains revoking intent and refuses queued admission',{timeout:30000},async t=>{
const f=await ownershipFixture(t,{listed:false}),put=f.hold('PUT'),h=f.hire();await put.entered();const p=f.preflight();f.bumpRevision();f.state.failRemoval=true;put.release();
await refused(h,'revision-conflict');await done(p);assert.equal(f.record()?.state,'revoking','failed removal keeps durable nonlaunchable intent');
assert.equal(!p.ok&&p.code==='proxy-key-revoking',true,'queued admission refuses retained revoking intent');assert.equal(f.listed('aide'),false);assert.equal(fs.existsSync(join(f.root,'config','secrets','agents','acme','aide','model')),true);f.valueFree();
await refused(h,'decision-stale');await done(p);assert.equal(f.record()?.state,'revoking','failed removal keeps durable nonlaunchable intent');
assert.equal(!p.ok&&p.code==='unknown-role',true,'unpublished membership refuses admission while model record retains revoking intent');assert.equal(f.listed('aide'),false);assert.equal(fs.existsSync(join(f.root,'config','secrets','agents','acme','aide','model')),true);f.valueFree();
});
test('655 no-launch remains before own preflight and grants no consumer or stop authority',{timeout:30000},async t=>{
+27 -9
View File
@@ -1,3 +1,6 @@
import {materializeCopies,withCopies} from '../../organization/src/agent-copies.mjs';
import {loadRootAgents} from '../../organization/src/agent-levels.mjs';
import { approveTemplateHire, ownerConsole } from './hire-fixtures.mjs';
// P2: real private Credentials, fake loopback management/model server. No live config.
import test from 'node:test';
import assert from 'node:assert/strict';
@@ -173,7 +176,7 @@ test('proxy-stop-failed: old proxy key and retiring record survive a failed grac
stopped = true; await overdue.sweep(); assert.equal(f.state.keys.includes(first.key), false);
});
async function bootBusiness(f, binding = {}) {
async function bootBusiness(f, {copyLevel = null,...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\n## Authority\n');
@@ -181,23 +184,26 @@ async function bootBusiness(f, binding = {}) {
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'] };
doc.roles['sec-1'] = { definition: 'security-reviewer' }; doc.launch.instances.push('sec-1');
const copyEntry={definition:'aide',vars:{harness:'pi',model:'claude-opus-5-5'}};
if(copyLevel==='project')doc.projects.stack.agents={aide:copyEntry};
if(copyLevel==='root')writeJson(join(f.configDir,'agents.json'),{agentsVersion:1,agents:{aide:copyEntry}});
const copies=copyLevel?materializeCopies({orgId:'demo',doc,rolesDir,root:loadRootAgents(f.configDir,{rolesDir})}):null;
const file = writeJson(join(f.configDir, 'businesses', 'demo.json'), doc);
fs.writeFileSync(join(f.configDir, 'secrets', 'proxy-management.key'), 'fx331-manage', { mode: 0o600 });
const business = loadBusiness('demo', { dir: f.configDir, rolesDir }), resolved = {};
const business = copies?withCopies(doc,file,{rolesDir,snapshot:copies}):loadBusiness('demo', { dir: f.configDir, rolesDir }), resolved = {};
for (const instance of Object.keys(business.roles)) resolved[instance] = resolveInstance({ system, business, instance });
fs.mkdirSync(join(f.root, 'data'), { mode: 0o700 });
// Fresh mkdtemp fixture + exclusive mkdir above: no prior session registry can exist.
new Registry(join(f.root, 'data'));
const bus = await startBroker({ dataRoot: join(f.root, 'data'), businesses: { demo: busOrganization(business, resolved) },
organization: { dir: f.configDir, rolesDir, system }, proxy: { port: Number(new URL(f.baseUrl).port), binary: '/fixture-unused' }, ...binding });
copies, organization: { dir: f.configDir, rolesDir, system }, proxy: { port: Number(new URL(f.baseUrl).port), binary: '/fixture-unused' }, ...binding });
f.closers.push(() => bus.close());
const chief = bus.broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
bus.broker.request(chief, { verb: 'role.claim' });
const fire = () => bus.broker.request(chief, { verb: 'instance.fire', args: { name: 'sec-1', reason: 'fixture fire' } });
const fire = () => bus.broker.request(ownerConsole(bus.broker), { verb: 'hire.start.console', args: { op:'fire',level:'org',name: 'sec-1', reason: 'fixture fire' } });
const listed = () => JSON.parse(fs.readFileSync(file, 'utf8')).launch.instances.includes('sec-1');
return { bus, chief, fire, listed, file, rolesDir, system };
}
@@ -233,7 +239,7 @@ test('proxy-revoke-revision-conflict: racing organization write keeps record, ne
});
test('bus hire issues and confirms implicit model key before publishing new membership', async t => {
const f = await fixture(t); const b = await bootBusiness(f); const calls = f.state.calls.length;
const result = await b.bus.broker.request(b.chief, { verb: 'instance.hire', args: { name: 'sec-new', definition: 'aide', reason: 'fixture hire' } });
const result = await approveTemplateHire(b.bus.broker,b.rolesDir,{name:'sec-new',definition:'aide'});
assert.equal(typeof result.transaction, 'string');
assert.equal(JSON.parse(fs.readFileSync(b.file, 'utf8')).launch.instances.includes('sec-new'), true);
assert.equal(b.bus.credentials.modelRecord('demo/sec-new')?.state, 'valid');
@@ -242,7 +248,7 @@ test('bus hire issues and confirms implicit model key before publishing new memb
test('proxy-key-issue-failed: bus hire failure publishes neither membership nor model file', async t => {
const f = await fixture(t); const b = await bootBusiness(f); f.state.failPut = true;
const before = fs.readFileSync(b.file, 'utf8');
await assert.rejects(b.bus.broker.request(b.chief, { verb: 'instance.hire', args: { name: 'sec-new', definition: 'aide', reason: 'fixture hire' } }), failCode('proxy-key-issue-failed'));
await assert.rejects(approveTemplateHire(b.bus.broker,b.rolesDir,{name:'sec-new',definition:'aide'}), failCode('proxy-key-issue-failed'));
assert.equal(fs.readFileSync(b.file, 'utf8'), before); assert.equal(b.bus.credentials.modelRecord('demo/sec-new') === null, true);
});
test('proxy-open: removing sentinel after boot refuses the next launch before models probe', async t => {
@@ -252,7 +258,7 @@ test('proxy-open: removing sentinel after boot refuses the next launch before mo
});
test('newly hired role can use runtime preflight without a broker restart', async t => {
const f = await fixture(t); const b = await bootBusiness(f);
await b.bus.broker.request(b.chief, { verb: 'instance.hire', args: { name: 'sec-new', definition: 'aide', reason: 'fixture hire' } });
await approveTemplateHire(b.bus.broker,b.rolesDir,{name:'sec-new',definition:'aide'});
const proxy = await b.bus.modelPreflight({ business: 'demo', role: 'sec-new' });
assert.equal(proxy.baseUrl, f.baseUrl); assert.equal(f.state.keys.includes(proxy.key), true);
});
@@ -328,3 +334,15 @@ test('beta bus registers while alpha bus runs and alpha key stays accepted', asy
assert.ok(f.state.keys.some(value => value.startsWith(`mso-${hash(f.configDir)}-beta-sentinel-`)));
assert.equal((await alpha.bus.modelPreflight({ business: 'demo', role: 'sec-1' })).key, key);
});
for(const level of ['root','project'])test(`A4 ${level} fire removes its implicit model key before publication`,async t=>{
const f=await fixture(t),b=await bootBusiness(f,{copyLevel:level});
const instance=level==='root'?'aide':'aide.stack';
const issued=await b.bus.modelPreflight({business:'demo',role:instance});
assert.ok(issued.key);
await b.bus.broker.request(ownerConsole(b.bus.broker),{verb:'hire.start.console',args:{op:'fire',level,...(level==='project'?{project:'stack'}:{}),name:'aide',reason:'Copy work finished'}});
assert.equal(f.state.keys.includes(issued.key),false,'remote generation is revoked');
assert.equal(b.bus.credentials.modelRecord('demo/'+instance),null,'private generation is removed');
const human=b.bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
assert.throws(()=>b.bus.broker.request(human,{verb:'instance.launch',args:{instance}}));
});
@@ -1,3 +1,4 @@
import { approveTemplateHire } from './hire-fixtures.mjs';
// #651 actual runtime + organization.hire publication with orphan private records.
// In-process trusted Broker, real SQLite/organization writer/HTTP; not process IPC.
import test from 'node:test';
@@ -16,7 +17,7 @@ async function hireFixture(t,{revoking=false}={}){
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\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 chiefFile=join(rolesDir,'chief-of-staff.json');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});
const dataRoot=join(f.root,'data');fs.mkdirSync(dataRoot,{mode:0o700});
@@ -27,7 +28,7 @@ async function hireFixture(t,{revoking=false}={}){
assert.equal(f.state.keys.includes(issued.key),false,'runtime boot reconciled omitted orphan');assert.equal(bus.credentials.modelRecord('demo/orphan')===null,true,'orphan remains unregistered before hire');
const cap=bus.broker.bindLaunch({business:'demo',role:'chief',run:'orphan-hire-chief',harness:'pi'});bus.broker.request(cap,{verb:'role.claim'});
const listed=()=>JSON.parse(fs.readFileSync(file,'utf8')).launch.instances.includes('orphan'),observed=[];f.state.observePut=()=>observed.push(listed());f.state.calls=[];
const hire=()=>bus.broker.request(cap,{verb:'instance.hire',args:{name:'orphan',definition:'aide',reason:'fixture orphan hire'}});
const hire=()=>approveTemplateHire(bus.broker,rolesDir,{name:'orphan',definition:'aide'});
return {...f,bus,issued,file,log,listed,observed,hire};
}
test('L3b runtime hire publishes orphan only after fresh remote confirmation',async t=>{
+4 -2
View File
@@ -1,3 +1,5 @@
import { readTemplate } from '../../organization/src/definitions.mjs';
import { ownerConsole } from './hire-fixtures.mjs';
// #654 isolated real runtime/writer/controller with owned HTTP request gates.
import fs from 'node:fs';
import http from 'node:http';
@@ -22,7 +24,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\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']});
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 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);
@@ -44,7 +46,7 @@ export async function pendingFixture(t,{twoBusinesses=false}={}){
const holdNext=(method='GET')=>{assert.equal(state.nextGate,null,'only one owned HTTP hold armed');const g={method,entered:deferred(),release:deferred()};gates.push(g);state.nextGate=g;return {entered:()=>bounded(g.entered.promise,'owned lifecycle HTTP boundary not observed'),release:()=>g.release.resolve()};};
const listed=(business,name)=>JSON.parse(fs.readFileSync(files[business],'utf8')).launch.instances.includes(name);
const snapshot=business=>{const journal=join(configDir,'businesses','.history',business,'changes.jsonl');return {membership:hash(fs.readFileSync(files[business])),publicationJournal:fs.existsSync(journal)?hash(fs.readFileSync(journal)):null,modelStatus:hash(JSON.stringify(bus.credentials.status())),remote:hash(JSON.stringify(state.keys)),calls:state.calls.length};};
const hire=(business,name,definition='aide',cap=chief[business])=>track(Promise.resolve().then(()=>bus.broker.request(cap,{verb:'instance.hire',args:{name,definition,reason:'fixture pending hire'}})));
const hire=(business,name,definition='aide',cap=chief[business])=>track(Promise.resolve().then(()=>(async()=>{const d=await bus.broker.request(cap,{verb:'hire.raise',args:{op:'hire',level:'org',name,role:{template:definition,sha256:readTemplate(rolesDir,definition).sha256},route:{harness:'pi',model:'claude-opus-5-5'},reason:'fixture pending hire'}});return bus.broker.request(ownerConsole(bus.broker,business),{verb:'hire.resolve.console',args:{id:d.id,digest:d.hire.digest,choice:'yes'}});})()));
const busy=async promise=>{const result=await bounded(promise.then(()=>({code:'unexpected-success'}),e=>({code:e.code??'unexpected-error'})),'same-business refusal not observed');assert.equal(result.code,'organization-busy','second same-business lifecycle call refuses exact pending code');};
return {root,configDir,dataRoot,rolesDir,bus,state,receipts,chief,holdNext,listed,snapshot,hire,busy,valueFree,
healthy:async(business,name)=>{const value=await bus.modelPreflight({business,role:name});const record=bus.credentials.modelRecord(business+'/'+name);assert.equal(value.key===record.key,true);assert.equal(state.keys.includes(record.key),true,'published member retains confirmed model credential');},
@@ -1,3 +1,4 @@
import {ownerConsole} from './hire-fixtures.mjs';
// #656 STATIC isolated fixtures. No real config/key/proxy; no production seam.
import fs from 'node:fs';
import http from 'node:http';
@@ -74,7 +75,7 @@ export async function revocationFixture(t,{runtime=false,onRoot=()=>{},missingBo
syncBuiltinESMExports();restoreFs=()=>{Object.assign(fs,original);syncBuiltinESMExports();};
const record=()=>c.modelRecord(instance),exists=path=>{try{original.lstatSync(path);return true;}catch(error){if(error.code==='ENOENT')return false;throw error;}};
const seed=({matchingTmp=true,retiring=0,valid=false}={})=>{const current=key(),old=Array.from({length:retiring},()=>({key:key(),issuedAt:'2026-10-23T00:00:00.000Z',retireAt:'2099-01-01T00:00:00.000Z'})).map(r=>({...r,keyId:id(r.key)}));const value={key:current,keyId:id(current),issuedAt:'2026-10-24T00:00:00.000Z',rotateBy:'2099-01-01',state:valid?'valid':'revoking',...(valid?{}:{revokingAt:'2026-10-25T00:00:00.000Z'}),retiring:old};state.keys.push(current,...old.map(r=>r.key));c.writeModel(instance,value);if(matchingTmp){fs.copyFileSync(file,temporary);fs.chmodSync(temporary,0o600);}removedTmp=false;return value;};
const lifecycle=how=>track((async()=>{if(how==='fire')return await bus.broker.request(chief,{verb:'instance.fire',args:{name:'aide',reason:'fixture recognized tmp'}});const revision=writer.read('demo').revision;const request=bus.broker.request(human,{verb:'instances.set',args:{revision,instance:'aide',op:'delete'}});const route={business:'demo',request:request.request},take=bus.lifecycleTake(route);assert.equal(take.go,true,'real trusted delete request is taken');try{const result=await bus.lifecycleDelete(route);bus.lifecycleDone({...route,transaction:result.transaction});return result;}catch(error){bus.lifecycleDone({...route,refusal:error.code??'outcome-unknown'});throw error;}})());
const lifecycle=how=>track((async()=>{if(how==='fire')return await bus.broker.request(ownerConsole(bus.broker),{verb:'hire.start.console',args:{op:'fire',level:'org',name:'aide',reason:'fixture recognized tmp'}});const revision=writer.read('demo').revision;const request=bus.broker.request(human,{verb:'instances.set',args:{revision,instance:'aide',op:'delete'}});const route={business:'demo',request:request.request},take=bus.lifecycleTake(route);assert.equal(take.go,true,'real trusted delete request is taken');try{const result=await bus.lifecycleDelete(route);bus.lifecycleDone({...route,transaction:result.transaction});return result;}catch(error){bus.lifecycleDone({...route,refusal:error.code??'outcome-unknown'});throw error;}})());
const writeSessions=entries=>writeJson(sessionsFile(dataRoot),{sessionsVersion:SESSIONS_VERSION,sessions:entries});
const ownRun=async({wrongTicks=false,wrongKey=false,publish=true}={})=>{
const spawnOwn=async()=>{const handle=spawn('/usr/bin/sleep',['60'],{stdio:'ignore'}),closed=new Promise(resolve=>handle.once('close',resolve));const owned={handle,closed};children.push(owned);await once(handle,'spawn');const ticks=startTimeOf(handle.pid);assert.equal(typeof ticks,'string');return {...owned,ticks,exit:async()=>{if(handle.exitCode===null&&handle.signalCode===null)handle.kill('SIGTERM');await bounded(closed,'owned stand-in exit timeout');}};};
+1 -1
View File
@@ -41,7 +41,7 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
// Row 236 (#1742, #436b) reads giteaName and parseGiteaUrl for the Gitea grant path.
// FED R3 r5 (row 274, #414) reads covers, a user's grant check, from it.
// Row 219 reads THREAD_DEFINITIONS from the same vocabulary for creative thread admission.
"import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs';",
"import { pluginName, ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs';",
]);
assert.doesNotMatch(source, /\bimport\s*\(/);
assert.doesNotMatch(readFileSync(new URL('../src/proxy-page-vocabulary.mjs', import.meta.url), 'utf8'), /\bimport\b/);
+5 -6
View File
@@ -1,3 +1,4 @@
import {approveTemplateHire,ownerConsole} from './hire-fixtures.mjs';
// Row 194 (#1692, D3, T14): after every write that commits, the broker takes the business as a
// restart would load it, so a created instance binds with no restart. A definition change on a
// running instance refuses, and a file whose credential references changed isn't reloaded.
@@ -149,8 +150,6 @@ test('T19 a credentialed create answers restart true; issue works only after a r
// A, from the file, so the kept instance keeps its claim and isn't left firing.
test('T14 after a refused reload veto.set, instance.hire and instance.fire answer restart true, and a fire of a name kept since refuses before step A', async (t) => {
const f = await setup(t);
const roleFile = join(f.rolesDir, 'chief-of-staff.json');
writeFileSync(roleFile, JSON.stringify({ ...JSON.parse(readFileSync(roleFile, 'utf8')), hireable: ['chief-information-security-officer'] }, null, 2));
mkdirSync(join(f.root, 'secrets', 'agents'), { recursive: true, mode: 0o700 });
const chief = bind(f.bus, 'chief');
f.bus.broker.request(chief, { verb: 'role.claim' });
@@ -162,21 +161,21 @@ test('T14 after a refused reload veto.set, instance.hire and instance.fire answe
const added = f.ask('instances.set', { revision: f.revision(), instance: 'rev-2', op: 'add', definition: 'reviewer', tracker: { bot: 'bot-demo-rev-2', botId: 40 } });
assert.equal(added.restart, true);
const hired = asChief('instance.hire', { name: 'sec-2', definition: 'chief-information-security-officer', reason: 'a security lead' });
assert.equal(hired.restart, true);
const hired = await approveTemplateHire(f.bus.broker,f.rolesDir,{name:'sec-2',definition:'chief-information-security-officer'});
assert.ok(hired.transaction);
const kept = f.ask('veto.set', { revision: f.revision(), op: 'keep', name: 'reviewer' });
assert.equal(kept.restart, true);
assert.equal(f.reloads.length, 0, 'the running copy still has reviewer unkept');
const before = readFileSync(f.file, 'utf8');
assert.equal(code(() => asChief('instance.fire', { name: 'reviewer', reason: 'the work is done' })), 'instance-kept');
await assert.rejects(f.bus.broker.request(ownerConsole(f.bus.broker),{verb:'hire.start.console',args:{op:'fire',level:'org',name:'reviewer',reason:'the work is done'}}),{code:'instance-kept'});
assert.equal(readFileSync(f.file, 'utf8'), before);
assert.deepEqual(f.ask('instances').instances.reviewer.running, run, 'step A never ran: the claim is held');
assert.notEqual(code(() => f.ask('instance.launch', { instance: 'reviewer' })), 'instance-firing');
const unkept = f.ask('veto.set', { revision: f.revision(), op: 'unkeep', name: 'reviewer' });
assert.equal(unkept.restart, true);
const fired = asChief('instance.fire', { name: 'reviewer', reason: 'the work is done' });
const fired = await f.bus.broker.request(ownerConsole(f.bus.broker),{verb:'hire.start.console',args:{op:'fire',level:'org',name:'reviewer',reason:'the work is done'}});
assert.equal(fired.restart, true);
assert.equal(JSON.parse(readFileSync(f.file, 'utf8')).launch.instances.includes('reviewer'), false);
});
+1 -1
View File
@@ -90,7 +90,7 @@ const ask = (x, state, extra = {}) => x.call('pm', 'request.question', { request
options: [{ key: 'web', text: 'Web' }, { key: 'cli', text: 'CLI' }], ...extra });
test('the console verb list includes the artifact read and existing owner controls', () => {
assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']);
});
+1 -1
View File
@@ -385,7 +385,7 @@ test('R3 r4: the chief\'s hire refuses an instance named user with the code it g
caps.chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi', address: 'chief' });
b.request(caps.chief, { verb: 'role.claim' });
const hire = (name) => code('chief', 'instance.hire', { name, definition: 'coder', reason: 'more hands' });
for (const name of ['human', 'tester', 'user']) assert.equal(hire(name), 'invalid-request', name);
for (const name of ['human', 'tester', 'user']) assert.equal(hire(name), 'decision-required', name);
// A name that isn't reserved passes the name check and refuses later, for want of role files.
assert.notEqual(hire('helper'), 'invalid-request');
});
@@ -0,0 +1,2 @@
// Old path of packages/organization/src/definition-verbs.mjs (rename design r2, stage B).
export * from "../../organization/src/definition-verbs.mjs";
+2
View File
@@ -0,0 +1,2 @@
// Old path of packages/organization/src/definitions.mjs (rename design r2, stage B).
export * from "../../organization/src/definitions.mjs";
+1
View File
@@ -0,0 +1 @@
export * from "../../organization/src/hire-decisions.mjs";
+4 -2
View File
@@ -154,11 +154,13 @@ function pidAlive(pid) {
}
}
// Names the lock by label: its path sits beside the access file in the config
// directory, and a refusal never quotes a host path (PR #1916).
function lockRefusal(lock) {
const pid = lockPid(lock);
if (pid !== null && pidAlive(pid)) return BUSY;
if (pid === null) return `refused: lock ${lock} is unreadable or not a pid; if no write is running, remove it by hand and try again`;
return `refused: lock ${lock} is left by pid ${pid}, which is not running; if no write is running, remove it by hand and try again`;
if (pid === null) return `refused: access file lock is unreadable or not a pid; if no write is running, remove it by hand and try again`;
return `refused: access file lock is left by pid ${pid}, which is not running; if no write is running, remove it by hand and try again`;
}
function takeLock(lock, fs) {
+5 -3
View File
@@ -37,8 +37,10 @@ function parse(args) {
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 => {
// The registry checked `id` against the id pattern, so the label may name it; the
// root is a host path and never reaches a refusal (PR #1916).
function readOwner(root, id) {
const owner = readJsonFile(join(root, 'owner.json'), `organization owner of ${id}`, 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');
@@ -92,7 +94,7 @@ export async function accountsCommand(args, io, deps) {
// 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); }
try { owner = readOwner(entry.root, entry.id); }
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);
}
+9 -1
View File
@@ -18,6 +18,7 @@ import { interviewCommand, INTERVIEW_USAGE, northStarCommand, NORTH_STAR_USAGE }
import { resolveThread, threadCommand, threadMembers, threadsCommand, THREAD_TALK_USAGE, THREAD_USAGE, THREADS_USAGE } from "./thread.mjs";
import { sessionCommand, SESSION_USAGE } from "./session.mjs";
import { limitsCommand, LIMITS_USAGE } from "./limits.mjs";
import { pluginInstallCommand } from "./plugin-install.mjs";
import { pluginsCommand, PLUGINS_USAGE } from "./plugins.mjs";
import { proxyCommand } from "./model-proxy.mjs";
import { credentialsCommand, CREDENTIALS_USAGE } from "./credentials.mjs";
@@ -36,6 +37,7 @@ import { bootConfig, loadSystem, orgPaths, socketPath } from "./config.mjs";
import { bootLine, readBootStatus, recordBootOk, recordBootRefused } from "./boot-status.mjs";
import { businessFilePath, configDir } from "../../organization/src/index.mjs";
import { busExit, humanTransport, refuseInsideAgent, testerTransport } from "./transport.mjs";
import { definitionsCommand } from "./org-definitions.mjs";
import { formatAgents, formatDecision, formatInbox, formatTasks, formatTrail, shortId } from "./format.mjs";
import { hostStatus, readHostState, startHost, stopHost } from "./host.mjs";
import { createLauncher } from "./launcher.mjs";
@@ -220,7 +222,7 @@ function callFor(parsed, io, deps) {
// Only the converted project/thread surface uses a channel. Management stays on callFor.
function channelCallFor(parsed, io, deps) {
const call = callFor(parsed, io, deps);
const channel = createChannelCaller({ name: "cli", call,
const channel = createChannelCaller({ name: "cli", call, dataRoot:(deps.system ?? loadSystem({env:io.env})).dataRoot,
log: entry => io.stderr.write(`mosaic: channel module refused (${entry.code})\n`),
});
return Object.assign(async (verb, args = {}) => {
@@ -736,6 +738,8 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
// builds; `org validate FILE` stays orgCommand's draft check.
if (verb === 'org' && rest[0] === 'config') return orgConfig(rest.slice(1), io);
if (verb === 'org' && ["instances", "instance", "veto"].includes(rest[0])) return orgInstancesCommand(rest, io, (p) => callFor(p, io, deps));
// #1873 T1: definition writes go through the organization's bus, as the human.
if (verb === 'org' && rest[0] === 'definitions') return definitionsCommand(rest.slice(1), io, { ...deps, callFor: (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);
@@ -759,6 +763,10 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
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" && ["install","list","remove"].includes(rest[0])) {
refuseInsideAgent(io.env);
return pluginInstallCommand(rest,io,{system:deps.system ?? loadSystem({env:io.env}),dir:deps.accountsConfigDir});
}
if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps));
// Row 199 (#1697): issue and rotate read the token on stdin.
if (verb === "proxy") return proxyCommand(rest, io, { system: () => deps.system ?? loadSystem({ env: io.env }), callFor: p => callFor(p, io, deps), prompt: deps.promptProxyKey });
+5 -5
View File
@@ -153,10 +153,10 @@ export const consoleOwners = (sso) =>
// 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 = () => {} }) {
export function copiesAtBoot({ businessId, env = process.env, warn = () => {}, dataRoot = null }) {
const dir = configDir(env), roles = rolesDir(env);
loadBusiness(businessId, { dir, rolesDir: roles });
const { document } = organizationWriter({ dir, rolesDir: roles }).read(businessId);
loadBusiness(businessId, { dir, rolesDir: roles, dataRoot });
const { document } = organizationWriter({ dir, rolesDir: roles, dataRoot }).read(businessId);
let root = null;
try { root = loadRootAgents(dir, { rolesDir: roles }); }
catch (error) {
@@ -164,13 +164,13 @@ export function copiesAtBoot({ businessId, env = process.env, warn = () => {} })
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 }) };
return { snapshot, business: withCopies(document, businessFilePath(businessId, dir), { rolesDir: roles, snapshot, dataRoot }) };
}
export function bootConfig({ system, businessId, orgRoot = system.dataRoot, env = process.env, warn = () => {} }) {
return business(() => {
const vars = systemVars(system);
const { snapshot, business: b } = copiesAtBoot({ businessId, env, warn });
const { snapshot, business: b } = copiesAtBoot({ businessId, env, warn, dataRoot:system.dataRoot });
const resolved = {};
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: vars, business: b, instance });
const config = {
+6 -6
View File
@@ -46,7 +46,7 @@ function noCredentials(env) {
function ownedPrivate(path, directory) {
const st = lstatSync(path);
if (st.isSymbolicLink() || (directory ? !st.isDirectory() : !st.isFile()) || st.uid !== process.getuid() || (st.mode & 0o077))
throw refuse('fixture-state-unsafe', `${path} is not a private ${directory ? 'directory' : 'file'} of this user`);
throw refuse('fixture-state-unsafe', `${directory ? 'dev-fixture directory' : 'fixture file'} is not a private ${directory ? 'directory' : 'file'} of this user`);
}
// The recorded fixture, or null when there is none. Anything malformed refuses.
@@ -56,19 +56,19 @@ export function readFixture(dataRoot) {
catch (e) { if (e.code === 'ENOENT') return null; throw e; }
let fd;
try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW); }
catch (e) { if (e.code === 'ENOENT') return null; throw refuse('fixture-state-unsafe', `${file} can't be opened (${e.code})`); }
catch (e) { if (e.code === 'ENOENT') return null; throw refuse('fixture-state-unsafe', `fixture.json can't be opened (${e.code})`); }
let doc;
try {
const st = fstatSync(fd);
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) throw refuse('fixture-state-unsafe', `${file} is not a private file of this user`);
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) throw refuse('fixture-state-unsafe', 'fixture.json is not a private file of this user');
doc = JSON.parse(readFileSync(fd, 'utf8'));
} catch (e) { if (e instanceof CliError) throw e; throw refuse('fixture-state-invalid', `${file} is not JSON`); }
} catch (e) { if (e instanceof CliError) throw e; throw refuse('fixture-state-invalid', 'fixture.json is not JSON'); }
finally { closeSync(fd); }
const m = LOOPBACK.exec(doc?.endpoint ?? '');
if (!m) throw refuse('fixture-endpoint-not-loopback', `${file} names an endpoint that is not http://127.0.0.1:<port>`);
if (!m) throw refuse('fixture-endpoint-not-loopback', 'fixture.json names an endpoint that is not http://127.0.0.1:<port>');
if (doc.fixtureVersion !== 1 || doc.model !== 'deterministic' || Number(m[1]) !== doc.port || !Number.isSafeInteger(doc.pid) || doc.pid < 2
|| typeof doc.instance !== 'string' || JSON.stringify(doc.entry) !== JSON.stringify(FIXTURE_ENTRY))
throw refuse('fixture-state-invalid', `${file} is not a fixture record`);
throw refuse('fixture-state-invalid', 'fixture.json is not a fixture record');
return doc;
}
+7 -4
View File
@@ -79,12 +79,13 @@ import { spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
import { closeSync, createReadStream, existsSync, lstatSync, mkdirSync, openSync, rmSync, statSync, symlinkSync, writeSync, chmodSync } from "node:fs";
import { createServer } from "node:net";
import { join } from "node:path";
import { basename, join } from "node:path";
import { GIT_GRACE_MS, GIT_TIMEOUT_MS } from "../../seat/src/checkout.mjs";
import { gitSocketPath } from "../../seat/src/session.mjs";
import { gateEnv, isMosaicStack, rootHits, runGate } from "../../seat/src/landing.mjs";
import { BUNDLE_MAX, dropLand, finishLand, FSCK, HAVE_MAX, prepareLand, publish, push, review, validBranch, workRef, WorkingError } from "../../seat/src/working.mjs";
import { CliError } from "./errors.mjs";
import { ID_PATTERN } from "../../organization/src/vocabulary.mjs";
import { withProcessing } from '../../processing-control/src/effects.mjs';
const HEADER_MAX = 4096;
@@ -385,7 +386,7 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
if (!p) throw new Refusal("unknown-project");
mkdirSync(logs, { recursive: true, mode: 0o700 });
prepared = await prepareLand({ ...where, root: p.root, taskRef: task_ref, commit: facts.commit, dir: logs, name: id, rootHits, message: (m) => landMessage(facts, m) });
if (!(await gate.classify(p.root, env))) throw new Refusal("gate-missing", `${p.root} isn't a mosaic-stack checkout with node_modules`);
if (!(await gate.classify(p.root, env))) throw new Refusal("gate-missing", `project ${facts.project} isn't a mosaic-stack checkout with node_modules`);
symlinkSync(join(p.root, "node_modules"), join(prepared.worktree, "node_modules"));
} catch (e) {
if (prepared) await dropLand({ ...where, worktree: prepared.worktree }).catch((x) => log(`git socket: ${id}: ${x.message}`));
@@ -398,7 +399,9 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
const r = await gate.run({ worktree: prepared.worktree, logs, env: gateEnv(env), signal: AbortSignal.any([slot.abort.signal, scope.signal]) });
scope.guard();
if (r.aborted || slot.abort.signal.aborted) throw new Refusal("host-closing", "the host closed during the gate; nothing moved");
if (!r.ok) throw new Refusal("gate-failed", `${r.step} failed, see ${r.log}`);
// The detail reaches the role, so the log is named inside the bus host directory, never by
// host path (PR #1916); its file name only when it passes the id pattern.
if (!r.ok) throw new Refusal("gate-failed", `${r.step} failed, see land/${id}${ID_PATTERN.test(basename(r.log ?? "")) ? `/${basename(r.log)}` : ""} in the bus host directory`);
await finishLand({ ...where, land: prepared, record: (m) => op({ op: "recordLand", ticket, land: { ...m, reviewed, base, patch_id } }) });
} finally {
await dropLand({ ...where, worktree: prepared.worktree }).catch((x) => log(`git socket: ${id}: ${x.message}`));
@@ -503,7 +506,7 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
async function listen() {
if (existsSync(path)) {
if (!lstatSync(path).isSocket()) throw new CliError(`${path} exists and isn't a socket`, 3);
if (!lstatSync(path).isSocket()) throw new CliError("git socket path exists and isn't a socket", 3);
rmSync(path);
}
server = createServer((socket) => {
+4 -4
View File
@@ -54,10 +54,10 @@ function readState(file, found = () => {}) {
state = JSON.parse(readFileSync(file, "utf8"));
} catch (e) {
if (e.code === "ENOENT") return null;
throw new CliError(`bus host state is unreadable: ${file}`, 3);
throw new CliError("bus host state is unreadable", 3);
}
if (!state || !Number.isSafeInteger(state.pid) || typeof state.startTime !== "string" || typeof state.business !== "string") {
throw new CliError(`bus host state is malformed: ${file}`, 3);
throw new CliError("bus host state is malformed", 3);
}
return { ...state, live: startTimeOf(state.pid) === state.startTime };
}
@@ -94,7 +94,7 @@ export function claimHostLock(dataRoot, state, { found = () => {}, between = ()
const prior = readState(file, found);
if (!prior) continue;
if (prior.live) throw new CliError(`a bus host already runs for ${prior.business} (pid ${prior.pid})`, 3);
if (refuseStale) throw new CliError(`stale bus host lock: ${file}`, 3);
if (refuseStale) throw new CliError("stale bus host lock", 3);
between();
try {
renameSync(file, aside);
@@ -111,7 +111,7 @@ export function claimHostLock(dataRoot, state, { found = () => {}, between = ()
}
rmSync(aside, { force: true });
}
throw new CliError(`${file} kept changing; no bus host started`, 3);
throw new CliError("bus host lock kept changing; no bus host started", 3);
} finally {
rmSync(tmp, { force: true });
}
+3 -1
View File
@@ -120,7 +120,9 @@ function setArgs(parsed) {
try {
args.vars = JSON.parse(readFileSync(values["--vars"], "utf8"));
} catch (error) {
throw new CliError(`--vars ${values["--vars"]}: ${error.message}`, 4);
// Name the flag and the error kind, never the file's path or bytes (PR #1916).
const code = typeof error?.code === "string" && /^E[A-Z0-9]+$/.test(error.code) ? error.code : "unreadable";
throw new CliError(`--vars file: ${error instanceof SyntaxError ? "not valid JSON" : code}`, 4);
}
}
if (launch !== undefined) args.launch = launch;
+9 -9
View File
@@ -261,7 +261,7 @@ 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");
const loadOrg = () => loadWithCopies(host.business, { dir: configDir(env), rolesDir: rolesDir(env), snapshot: copies?.org === host.business ? copies : null });
const loadOrg = () => loadWithCopies(host.business, { dir: configDir(env), rolesDir: rolesDir(env), dataRoot:system.instanceRoot ?? dataRoot, snapshot: copies?.org === host.business ? copies : null });
let b = loadOrg();
const vars = systemVars(system);
@@ -358,9 +358,9 @@ export function createLauncher({
const admissionGeneration = admission().generation;
let businessRevision;
try {
const snapshot = organizationWriter({dir:configDir(env),rolesDir:rolesDir(env)}).read(host.business);
const snapshot = organizationWriter({dir:configDir(env),rolesDir:rolesDir(env),dataRoot:system.instanceRoot ?? dataRoot}).read(host.business);
businessRevision = snapshot.revision;
b = withCopies(snapshot.document, businessFilePath(host.business, configDir(env)), {rolesDir:rolesDir(env), snapshot: copies?.org === host.business ? copies : null});
b = withCopies(snapshot.document, businessFilePath(host.business, configDir(env)), {rolesDir:rolesDir(env), dataRoot:system.instanceRoot ?? dataRoot, 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`);
// #1873 A3: an unbound or refused copy never launches. The message names the copy, its project
@@ -382,16 +382,16 @@ export function createLauncher({
throw new Refusal("legacy-run-live", `a run of ${instance} with no session is still live`);
let resolved;
try {
resolved = resolveInstance({ system: vars, business: b, instance });
resolved = resolveInstance({ system: vars, business: b, instance, enforcePlugins:true, pluginRoot:system.instanceRoot ?? dataRoot });
} catch (e) {
throw new Refusal("unsupported-harness", e.message);
throw new Refusal(e.code === "plugin-disabled" ? "plugin-disabled" : "unsupported-harness", e.code === "plugin-disabled" ? e.plugin : e.message);
}
// Resolve the business's current provider modules with a fresh loader; a refused
// load never inherits a previous business's authority.
const providerNames = b.plugins?.['model-provider'] ?? [];
const providerModules = new Map();
if (providerNames.length) {
const loader = makeLoader({ log: entry => record({ ...entry, instance }) });
const loader = makeLoader({ dataRoot:system.instanceRoot ?? dataRoot, log: entry => record({ ...entry, instance }) });
const loaded = await loader.load({ 'model-provider': providerNames });
if (loaded.refused.length) throw new Refusal('model-provider-unavailable', 'model provider module refused');
for (const { name, module } of loader.snapshot('model-provider')) providerModules.set(name, module);
@@ -412,7 +412,7 @@ export function createLauncher({
};
const primary = routeOf("primary");
const fallback = hasFallback(resolved) ? routeOf("fallback") : null;
if (!resolved.contract) throw new Refusal("no-role-contract", `role ${resolved.definition} has no contract file`);
if (!resolved.contract && typeof resolved.instructions !== "string") throw new Refusal("no-role-contract", `role ${resolved.definition} has no contract file`);
// Row 64 (#1557): every skill on the role's list is enabled and readable before a run exists.
try {
readSkills(resolved.skills, skillsDir);
@@ -427,7 +427,7 @@ export function createLauncher({
}
// One harness through a fresh loader, so a refusal never leaves an earlier load's module in use.
const harnessOf = async (m) => {
const loader = makeLoader({ log: (entry) => record({ ...entry, instance }) });
const loader = makeLoader({ dataRoot:system.instanceRoot ?? dataRoot, log: (entry) => record({ ...entry, instance }) });
const { refused } = await loader.load({ harness: [m.harness] });
if (refused.length) throw new Refusal("harness-refused", `harness ${m.harness}: ${refused.map((r) => `${r.code}: ${r.message}`).join("; ")}`);
const module = loader.modules("harness")[0];
@@ -723,7 +723,7 @@ export function createLauncher({
models: b.launch.models,
proxy: sessionProxy,
resolver,
contract: readFileSync(resolved.contract, "utf8"),
contract: resolved.contract ? readFileSync(resolved.contract, "utf8") : resolved.instructions,
arbiter: Object.values(b.arbiters).includes(instance),
tracker,
library: Object.values(b.projects ?? {}).some(project => project.kind === "library"),
+10 -10
View File
@@ -162,28 +162,28 @@ export function openJournal(file, { log = () => {}, now = () => new Date() } = {
mkdirSync(dir, { recursive: true, mode: 0o700 });
} catch (e) {
// A dangling link in place of the directory fails mkdir with ENOENT.
if (e.code === "ENOENT" && lstatSync(dir, { throwIfNoEntry: false })?.isSymbolicLink()) throw new CliError(`notify journal directory must not be a symlink: ${dir}`, 3);
if ([...UNWRITABLE, "ENOTDIR"].includes(e.code)) throw new CliError(`notify journal directory cannot be created (${e.code}): ${dir}`, 3);
if (e.code === "ENOENT" && lstatSync(dir, { throwIfNoEntry: false })?.isSymbolicLink()) throw new CliError(`notify journal directory must not be a symlink`, 3);
if ([...UNWRITABLE, "ENOTDIR"].includes(e.code)) throw new CliError(`notify journal directory cannot be created (${e.code})`, 3);
if (e.code !== "EEXIST") throw e;
}
const ds = lstatSync(dir);
if (ds.isSymbolicLink()) throw new CliError(`notify journal directory must not be a symlink: ${dir}`, 3);
if (ds.isSymbolicLink()) throw new CliError(`notify journal directory must not be a symlink`, 3);
if (!ds.isDirectory() || ds.uid !== process.getuid() || (ds.mode & 0o077) !== 0) {
throw new CliError(`notify journal directory must be mode 0700 and owned by this user: ${dir}`, 3);
throw new CliError(`notify journal directory must be mode 0700 and owned by this user`, 3);
}
try {
accessSync(dir, constants.W_OK | constants.X_OK);
} catch (e) {
if (UNWRITABLE.includes(e.code)) throw new CliError(`notify journal directory is not writable (${e.code}): ${dir}`, 3);
if (UNWRITABLE.includes(e.code)) throw new CliError(`notify journal directory is not writable (${e.code})`, 3);
throw e;
}
let fd;
try {
fd = openSync(file, O_RDWR | O_APPEND | O_CREAT | O_NOFOLLOW, 0o600);
} catch (e) {
if (e.code === "ELOOP") throw new CliError(`notify journal must not be a symlink: ${file}`, 3);
if (UNWRITABLE.includes(e.code)) throw new CliError(`notify journal is not writable (${e.code}): ${file}`, 3);
if (e.code === "EISDIR") throw new CliError(`notify journal must be a regular file, mode 0600, owned by this user: ${file}`, 3);
if (e.code === "ELOOP") throw new CliError(`notify journal must not be a symlink`, 3);
if (UNWRITABLE.includes(e.code)) throw new CliError(`notify journal is not writable (${e.code})`, 3);
if (e.code === "EISDIR") throw new CliError(`notify journal must be a regular file, mode 0600, owned by this user`, 3);
throw e;
}
const sent = new Set();
@@ -204,7 +204,7 @@ export function openJournal(file, { log = () => {}, now = () => new Date() } = {
try {
const st = fstatSync(fd);
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) {
throw new CliError(`notify journal must be a regular file, mode 0600, owned by this user: ${file}`, 3);
throw new CliError(`notify journal must be a regular file, mode 0600, owned by this user`, 3);
}
const bytes = readFileSync(fd);
const end = bytes.lastIndexOf(0x0a) + 1;
@@ -218,7 +218,7 @@ export function openJournal(file, { log = () => {}, now = () => new Date() } = {
r = null;
}
const bad = r === null ? "json" : badField(r);
if (bad) throw new CliError(`notify journal line ${i + 1} is malformed (${bad}): ${file}`, 3);
if (bad) throw new CliError(`notify journal line ${i + 1} is malformed (${bad})`, 3);
count(r);
});
if (end < bytes.length) {
+6 -5
View File
@@ -6,27 +6,28 @@ import { CliError } from "./errors.mjs";
// The notifier's private config: `<dataRoot>/notify/<business>/notify.json`,
// 0600, `{"notifyVersion": 1, "binding": "<discord binding>" | null}`.
// Missing refuses, so a host never starts without someone deciding whether
// it DMs; null runs the host without a notifier.
// it DMs; null runs the host without a notifier. Refusals name the business
// and the path under the data root, never the host path (PR #1916).
export function readNotifyConfig(dataRoot, business) {
const file = join(dataRoot, "notify", business, "notify.json");
let st;
try {
st = lstatSync(file);
} catch {
throw new CliError(`no notifier config at ${file}; write {"notifyVersion": 1, "binding": "<discord binding>"} there, mode 0600, or "binding": null to run without DMs`, 3);
throw new CliError(`no notifier config for ${business}: write {"notifyVersion": 1, "binding": "<discord binding>"} to notify/${business}/notify.json under the data root, mode 0600, or "binding": null to run without DMs`, 3);
}
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) {
throw new CliError(`notifier config must be a regular file, mode 0600, owned by this user: ${file}`, 3);
throw new CliError(`notifier config for ${business} must be a regular file, mode 0600, owned by this user`, 3);
}
let doc;
try {
doc = JSON.parse(readFileSync(file, "utf8"));
} catch {
throw new CliError(`notifier config is not JSON: ${file}`, 3);
throw new CliError(`notifier config for ${business} is not JSON`, 3);
}
const keys = doc && typeof doc === "object" && !Array.isArray(doc) ? Object.keys(doc).sort().join(",") : "";
if (keys !== "binding,notifyVersion" || doc.notifyVersion !== 1 || (doc.binding !== null && !(typeof doc.binding === "string" && BINDING_NAME.test(doc.binding)))) {
throw new CliError(`notifier config must be exactly {"notifyVersion": 1, "binding": <binding name> | null}: ${file}`, 3);
throw new CliError(`notifier config for ${business} must be exactly {"notifyVersion": 1, "binding": <binding name> | null}`, 3);
}
return doc.binding;
}
+3 -1
View File
@@ -138,7 +138,9 @@ export async function busStart(ctx, id, { detach = false, devLocalOwner = false,
await new Promise(r => setTimeout(r, 50));
}
if (!exited) { child.kill('SIGTERM'); await done; }
throw new CliError(`bus failed to start; see ${join(org.paths.hostDir, 'host.log')}`, 3);
// Name the log by its place in the organization's runtime directory, never by host path (PR #1916);
// resolveOrg admitted `id` only after the registry's id check.
throw new CliError(`bus failed to start; see bus-host/host.log in the runtime directory of organization ${id}`, 3);
}
export async function busRestart(ctx, id) {
const org = await target(ctx, id);
+5 -7
View File
@@ -4,7 +4,7 @@ import { processState } from '../../seat/src/process-state.mjs';
import { spawn } from 'node:child_process';
import { request as httpRequest } from 'node:http';
import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, renameSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { basename, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { listOrgs, resolveOrg } from '../../organization/src/org-registry.mjs';
import { cmdlineOf, startTimeOf } from './host.mjs';
@@ -95,7 +95,8 @@ async function launch(ctx, args, lan, detach) {
}
await sleep(50);
}
fail(`console failed to start; see ${log}`);
// The log is named under the data root, never by host path (PR #1916).
fail(`console failed to start; see console/${basename(log)} under the data root`);
} catch (e) {
if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await done; }
throw e;
@@ -193,11 +194,8 @@ export async function consoleStop(ctx, { warn = s => process.stderr.write(s) } =
const argv = cmdlineOf(s.pid);
if (!argv || !argv.length) fail('console process identity is uncertain');
const i = argv.indexOf(WEBUI);
if (i < 0 || argv[i + 1] !== 'serve') {
const serve = argv.indexOf('serve');
const entry = serve > 0 ? ` ${argv[serve - 1]}` : '';
fail(`console was started from another path${entry}; stop it there`);
}
// The other entry script is a host path, so the refusal never names it (PR #1916).
if (i < 0 || argv[i + 1] !== 'serve') fail('console was started from another checkout; stop it there');
process.kill(s.pid, 'SIGTERM');
const deadline = Date.now() + 20000;
while (Date.now() < deadline) {
+96
View File
@@ -0,0 +1,96 @@
// #1873 T1: `mosaic org definitions`, the human verbs on an organization's own role definitions and
// the template hash and diff reads. Reads run here, from the file. A write runs only from a human
// shell (refuseInsideAgent) and goes through the organization's bus as its human: the bus holds the
// credential store, whose held-secret check every definition write needs (R1), and no other process
// holds the tokens it looks for. Each write prints its receipt.
import { readFile } from 'node:fs/promises';
import { configDir } from '../../organization/src/organization-config.mjs';
import { organizationWriter } from '../../organization/src/organization.mjs';
import { definitionVerbs } from '../../organization/src/definition-verbs.mjs';
import { checkDefinition } from '../../organization/src/definitions.mjs';
import { rolesDir } from './config.mjs';
import { CliError } from './errors.mjs';
import { busExit, refuseInsideAgent } from './transport.mjs';
export const DEFINITIONS_USAGE = 'mosaic org definitions templates | definitions ID list | definitions ID show NAME | definitions ID diff NAME | definitions ID import TEMPLATE [--as NAME] | definitions ID invent NAME --from FILE | definitions ID edit NAME --from FILE --if-rev N | definitions ID remove NAME --if-rev N';
const WRITES = ['import', 'invent', 'edit', 'remove'];
const VALUES = ['--as', '--from', '--if-rev'];
function parse(args) {
const pos = [];
const values = {};
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (VALUES.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 };
}
async function definitionFile(file) {
try {
return JSON.parse(await readFile(file, 'utf8'));
} catch (e) {
// Never echo the file's text: it may hold what the composition check refuses.
throw new CliError(`cannot read definition ${file}: ${e instanceof SyntaxError ? 'not valid JSON' : e.code ?? 'unreadable'}`, 2);
}
}
// The bus answers a refusal with its code only. These say what the code means for this request;
// none quotes the definition's text.
function refusal(code, verb, name, rev, read) {
const why = {
'definition-in-use': () => `definition ${name} is named by ${read().usedBy.join(', ')}; a change to a hired agent's role is a change decision`,
'definition-rev-conflict': () => `definition ${name} is not at rev ${rev}`,
'definition-exists': () => `definitions already declares ${name}`,
'definition-missing': () => `definitions declares no ${name}`,
'revision-conflict': () => 'the organization file changed while this ran; run it again',
'credential-leak': () => `the ${verb} request holds a credential the organization's bus holds; nothing was written`,
'invalid-request': () => `the bus refused the ${verb}: the definition fails the composition check or holds a held credential`,
}[code];
let detail = null;
try { detail = why?.() ?? null; } catch { detail = null; }
const error = new CliError(detail ? `${code}: ${detail}` : code, busExit(code));
error.code = code;
return error;
}
// deps.callFor(parsed) returns the human's call(verb, args) for the organization parsed names (cli.mjs
// passes its callFor). Without one, a write refuses: it never runs without the bus's secret check.
export async function definitionsCommand(args, io, deps = {}) {
const { pos, values } = parse(args);
const allowed = (...keys) => { for (const k of Object.keys(values)) if (!keys.includes(k)) throw new CliError(`unexpected ${k}`, 4); };
const need = (key) => { if (!values[key]) throw new CliError(`${key} is required`, 4); return values[key]; };
const ifRev = () => { const r = need('--if-rev'); if (!/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid --if-rev', 4); return Number(r); };
const [id, verb, name] = pos;
if (WRITES.includes(verb)) refuseInsideAgent(io.env);
const writer = deps.writer ?? organizationWriter({ dir: configDir(io.env), rolesDir: rolesDir(io.env) });
const verbs = definitionVerbs({ writer, rolesDir: rolesDir(io.env) });
let result;
if (id === 'templates' && pos.length === 1) { allowed(); result = verbs.templates(); }
else if (verb === 'list' && pos.length === 2) { allowed(); result = verbs.list(id); }
else if (['show', 'diff'].includes(verb) && pos.length === 3) { allowed(); result = verbs[verb](id, name); }
else if (WRITES.includes(verb) && pos.length === 3) {
let request, rev;
if (verb === 'import') { allowed('--as'); request = { template: name, as: values['--as'] ?? name }; }
else if (verb === 'invent') { allowed('--from'); request = { name, definition: await definitionFile(need('--from')) }; }
else if (verb === 'edit') { allowed('--from', '--if-rev'); rev = ifRev(); request = { rev, name, definition: await definitionFile(need('--from')) }; }
else { allowed('--if-rev'); rev = ifRev(); request = { rev, name }; }
// The file's own checks first, so a bad key is named here; the bus repeats them with its secret check.
if (request.definition) checkDefinition(name, { ...request.definition, rev: rev ?? 1 });
if (typeof deps.callFor !== 'function') throw new CliError('a definition write goes through the organization bus; run it as `mosaic org definitions`', 3);
const call = deps.callFor({ positional: [id], flags: new Set(), values: { '--business': id } });
const { revision } = writer.read(id);
try {
result = await call(`definition.${verb}`, { revision, ...request });
} catch (e) {
if (typeof e?.code !== 'string' || e.code === '') throw e;
throw refusal(e.code, verb, request.as ?? name, rev, () => verbs.show(id, request.as ?? name));
}
} else throw new CliError(DEFINITIONS_USAGE, 4);
io.stdout.write(JSON.stringify(result, null, 2) + '\n');
}
+14 -3
View File
@@ -9,11 +9,16 @@ import { CliError } from './errors.mjs';
import { busStopForDelete } from './org-bus.mjs';
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]';
import { definitionsCommand, DEFINITIONS_USAGE } from './org-definitions.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] | ' + DEFINITIONS_USAGE.slice('mosaic org '.length) + ' | config validate ID | config resolve ID INSTANCE [--project ID]';
function parse(args) { const pos = [], values = {}, flags = new Set(); for (let i = 0; i < args.length; i++) { const a = args[i]; if (['--json', '--local-only', '--accept-local-loss-risk'].includes(a)) { if (flags.has(a)) throw new CliError(`duplicate ${a}`, 4); flags.add(a); } else if (['--fixture', '--output', '--from', '--if-revision', '--remote', '--confirm'].includes(a)) { if (values[a] !== undefined || !args[i+1] || args[i+1].startsWith('--')) throw new CliError(`${a} needs one value`, 4); values[a] = args[++i]; } else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4); else pos.push(a); } return { pos, values, flags }; }
async function document(file) { try { return JSON.parse(await readFile(file, 'utf8')); } catch (e) { throw new CliError(`cannot read document ${file}: ${e instanceof SyntaxError ? 'not valid JSON' : e.message}`, 2); } }
// A refusal names the document by label and the error by its code, never by host path or bytes (PR #1916).
const errorCode = e => typeof e?.code === 'string' && /^E[A-Z0-9]+$/.test(e.code) ? e.code : 'unreadable';
async function document(file) { try { return JSON.parse(await readFile(file, 'utf8')); } catch (e) { throw new CliError(`cannot read organization document: ${e instanceof SyntaxError ? 'not valid JSON' : errorCode(e)}`, 2); } }
export async function orgCommand(args, io, deps = {}) {
try {
// #1873 T1: the definitions verbs take their own options.
if (args[0] === 'definitions') return await definitionsCommand(args.slice(1), io, deps);
const p = parse(args); const words = p.pos; let result; let accepted = [];
const allowed = (flags, values) => { for (const f of p.flags) if (!flags.includes(f)) throw new CliError(`unexpected ${f}`, 4); for (const f of Object.keys(p.values)) if (!values.includes(f)) throw new CliError(`unexpected ${f}`, 4); };
const need = key => { if (!p.values[key]) throw new CliError(`${key} is required`, 4); return p.values[key]; };
@@ -41,5 +46,11 @@ export async function orgCommand(args, io, deps = {}) {
else throw new CliError(ORG_USAGE, 4);
} else throw new CliError(ORG_USAGE, 4);
io.stdout.write(JSON.stringify(result, null, 2) + '\n');
} catch (e) { if (e instanceof CliError) throw e; throw new CliError(`${e.code ? e.code + ': ' : ''}${e.message}`, 2); }
} catch (e) {
if (e instanceof CliError) throw e;
// A raw Node filesystem error ("CODE: description, syscall 'path'") is named by its operation
// and code, never by the host path in its message (PR #1916).
const message = typeof e?.syscall === 'string' && typeof e.code === 'string' && e.message.startsWith(`${e.code}:`) ? `cannot ${e.syscall} (${errorCode(e)})` : e.message;
throw new CliError(`${e.code ? e.code + ': ' : ''}${message}`, 2);
}
}
+38
View File
@@ -0,0 +1,38 @@
// Instance-level shell verbs. Intentionally separate from pluginsCommand's bus transport.
import { readdirSync } from 'node:fs';
import { join } from 'node:path';
import { CliError } from './errors.mjs';
import { refuseInsideAgent } from './transport.mjs';
import { configDir } from '../../organization/src/organization-config.mjs';
import { assertAccountsOwner } from '../../organization/src/accounts.mjs';
import { withRegistry } from '../../organization/src/org-registry.mjs';
import { readJsonFile } from '../../organization/src/util.mjs';
import { installPlugin, removePlugin } from '../../plugins/src/install.mjs';
import { readPluginLock } from '../../plugins/src/pinned.mjs';
export async function pluginInstallCommand(args,io,{system,dir=configDir(io.env)}) {
refuseInsideAgent(io.env);
try {
assertAccountsOwner(dir);
const [verb,value,...rest]=args;
if(!['install','list','remove'].includes(verb))throw new CliError('mosaic plugins install SOURCE --sha256 HASH [--commit FULL_SHA] | list | remove NAME',4);
const opts={};
if(verb==='install') {
for(let i=0;i<rest.length;i+=2){if(!['--sha256','--commit'].includes(rest[i]) || !rest[i+1] || Object.hasOwn(opts,rest[i]))throw new CliError('invalid plugin install options',4);opts[rest[i]]=rest[i+1];}
if(!value || !opts['--sha256'])throw new CliError('install requires SOURCE and --sha256 HASH',4);
}else if(rest.length || (verb==='list'?value!==undefined:!value))throw new CliError('invalid plugin management arguments',4);
let result;
if(verb==='list') result=readPluginLock(system.dataRoot);
else if(verb==='install')result=installPlugin({dataRoot:system.dataRoot,source:value,commit:opts['--commit'],expectedHash:opts['--sha256'],print:line=>io.stdout.write(line+'\n')});
else result=await withRegistry({configDir:dir,instance:system.instance ?? dir.split('/').pop().replace(/^mosaic-/, '')},()=>{
// All organization files, including archived organizations, must be readable. The registry
// lock prevents a simultaneous create from disappearing from this check.
let files=[];try{files=readdirSync(join(dir,'businesses'));}catch(e){if(e.code!=='ENOENT')throw e;}
return removePlugin({dataRoot:system.dataRoot,name:value,enabled:name=>files.filter(n=>n.endsWith('.json')).some(n=>{
const doc=readJsonFile(join(dir,'businesses',n),'organization file');
return Object.values(doc.plugins ?? {}).flat().includes(name);
})});
},{write:false});
io.stdout.write(JSON.stringify(result,null,2)+'\n');
}catch(e){if(e instanceof CliError)throw e;throw new CliError(`plugin management refused: ${/^[a-z-]+$/.test(e.code ?? '')?e.code:'plugin-refused'}`,3);}
}
+1 -1
View File
@@ -7,7 +7,7 @@ import { readFileSync } from "node:fs";
import { CliError } from "./errors.mjs";
import { orgSelector } from "./org-selector.mjs";
export const PLUGINS_USAGE = "mosaic plugins [--business ID] [--json] [--tester] | set --file PATCH [--business ID] [--tester]";
export const PLUGINS_USAGE = "mosaic plugins [--business ID] [--json] [--tester] | set --file PATCH [--business ID] [--tester] | install SOURCE --sha256 HASH [--commit FULL_SHA] | list | remove NAME";
function parse(args) {
const out = { positional: [], flags: new Set(), values: {} };
+2 -2
View File
@@ -63,7 +63,7 @@ const inside = (root, path) => { const r = relative(root, path); return r !== ''
export function threadRecords(file, thread) {
const log = fileEvidence(file);
if (!log.ok && log.problem === 'missing') return { file, missing: true, launches: [], ends: [], malformed: 0 };
if (!log.ok) throw new CliError(`run-evidence-refused: launch log ${file}: ${log.problem}`, 3);
if (!log.ok) throw new CliError(`run-evidence-refused: launch log: ${log.problem}`, 3);
const launches = [], ends = []; let malformed = 0;
log.bytes.toString('utf8').split('\n').forEach((line, i) => {
if (!line) return;
@@ -186,7 +186,7 @@ export async function runCommand(rest, io, deps = {}) {
lines.push(verify ? `verified: ${verified ? 'yes' : 'no'}` : 'not verified (pass --verify)');
io.stdout.write(`${lines.join('\n')}\n`);
}
if (runs.length === 0) throw new CliError(`no launch for thread ${thread.label} in ${log.file}`, 2);
if (runs.length === 0) throw new CliError(`no launch for thread ${thread.label} in the launch log`, 2);
if (verify && !verified) throw new CliError('run evidence failed verification', 1);
return report;
}
+4 -3
View File
@@ -148,7 +148,7 @@ test("a lock left by a dead pid refuses, names the pid, and is not reclaimed", a
const dead = spawnSync(process.execPath, ["-e", ""]);
writeFileSync(`${s.file}.lock`, `${dead.pid}\n`, { mode: 0o600 });
const before = readFileSync(s.file, "utf8");
await refusal(run([...GRANT, "--revision", "1"], s.deps), new RegExp(`${s.file}\\.lock is left by pid ${dead.pid}, which is not running`));
await refusal(run([...GRANT, "--revision", "1"], s.deps), new RegExp(`^refused: access file lock is left by pid ${dead.pid}, which is not running`));
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(readFileSync(`${s.file}.lock`, "utf8"), `${dead.pid}\n`);
assert.equal(s.receipts(), "");
@@ -165,13 +165,14 @@ test("a failed rename leaves the old bytes, removes the temp file, and writes no
assert.equal(s.receipts(), "");
});
test("a group-writable access file refuses with exit 3, names the file, and changes nothing", async (t) => {
test("a group-writable access file refuses with exit 3, names the file by label, never by path, and changes nothing", async (t) => {
const s = setup(t);
chmodSync(s.file, 0o664);
const before = readFileSync(s.file, "utf8");
const err = await refusal(run([...GRANT, "--revision", "1"], s.deps), /must not be writable by group or other/);
assert.equal(err.exitCode, 3);
assert.match(err.message, new RegExp(s.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
assert.equal(err.message, "refused: access file must not be writable by group or other (mode 664)");
assert.ok(!err.message.includes(s.file), err.message);
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts(), "");
assert.equal(existsSync(`${s.file}.lock`), false);
+14 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, chmodSync, writeFileSync, readFileSync, mkdirSync, existsSync, rmSync } from 'node:fs';
import { mkdtempSync, chmodSync, writeFileSync, readFileSync, mkdirSync, existsSync, rmSync, symlinkSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { accountsCommand } from '../src/accounts.mjs';
@@ -101,3 +101,16 @@ test('org-acct-anchor: init, list and remove refuse a 0777 ancestor before any l
chmodSync(parent, 0o1777);
await accountsCommand(['init'], io, deps); assert.match(output, /accounts init: u-[0-9a-f]{12}, revision 1/);
});
// PR #1916 (org-redact5): an unreadable owner file is named by the registry's checked
// id, never by the organization root's host path.
test('remove: an invalid or linked owner file is named by the org id, never by host path', async t => {
const SENTINEL = 'sk-test-NOT-A-SECRET-1234';
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, SENTINEL, 'broken'); mkdirSync(root, { recursive: true, 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'), '{not json', { mode: 0o600 }); });
const clean = (pattern) => e => { assert.equal(e.exitCode, 3); assert.match(e.message, pattern); assert.ok(!e.message.includes(SENTINEL) && !e.message.includes(root), e.message); return true; };
await assert.rejects(s.run(['user', 'remove', id, '--revision', '2']), clean(/^refused: .*organization owner of broken is not valid JSON$/));
rmSync(join(root, 'owner.json')); writeFileSync(join(root, 'real.json'), '{}', { mode: 0o600 }); symlinkSync(join(root, 'real.json'), join(root, 'owner.json'));
await assert.rejects(s.run(['user', 'remove', id, '--revision', '2']), clean(/^refused: .*organization owner of broken must be a regular, non-symbolic-link file$/));
assert.equal(s.read().users.length, 2, 'nothing was removed');
});
+3 -1
View File
@@ -72,7 +72,9 @@ test("an unknown key and a JSON syntax error each leave the bus down with the re
assert.equal(s.business, "acme");
assert.equal(s.file, file);
assert.match(s.reason, reason);
assert.ok(s.reason.includes(file) || /surprise/.test(s.reason));
// The reason names the file by label, never by host path (PR #1916); the path stays in `file`.
assert.ok(!s.reason.includes(file), s.reason);
assert.ok(/surprise/.test(s.reason) || /business file acme\.json is not valid JSON/.test(s.reason), s.reason);
// `bus status` shows it, in text and in JSON.
const out = run(f, "bus", "status", "acme", "--json");
assert.equal(out.status, 0, out.stderr);
@@ -0,0 +1,27 @@
// Invoked inside a private PID namespace. Check residual processes BEFORE its
// init exits: namespace teardown would otherwise mask a missed descendant kill.
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { instrumentCleanupTimer } from './case-inner-timing-fixture.mjs';
import { blockingQuerySource, killRecordedPids, recordedPids, recordedLivePids } from './case-sudo-fixture.mjs';
const [script,dir]=process.argv.slice(2),clock=join(dir,"clock '$;literal"),records=join(dir,'pids');
try {
writeFileSync(clock,'',{mode:0o600});
const worker=join(dir,'blocked.mjs');
writeFileSync(worker,blockingQuerySource(records,'sleep')+'\nloadSystem();');
const source=instrumentCleanupTimer(readFileSync(script,'utf8'),clock);
const body=source.slice(source.indexOf('# row253-supervisor-begin'),source.indexOf('# row253-supervisor-end'));
const result=spawnSync('/bin/bash',['-c',body+'\nexec /bin/bash -c "$TESTER_SUPERVISOR" -- 2 /usr/bin/setsid /usr/bin/sleep "$@"','--',process.execPath,worker],{
encoding:'utf8',timeout:12000,killSignal:'SIGKILL',
});
// These PIDs belong to this namespace; never use them to signal host PIDs.
process.stdout.write(JSON.stringify({
status:result.status,signal:result.signal,error:result.error?.message,
stdout:result.stdout,stderr:result.stderr,pairs:recordedPids(records),
live:recordedLivePids(records),clock:readFileSync(clock,'utf8'),
}));
} finally {
killRecordedPids(records);
}
@@ -0,0 +1,69 @@
// A delay in finish(), after timing2's end stamp, must fail independently of
// the inner timer. One fixed process tree runs at a time, without host setup,
// fake sudo, or a parent-side elapsed-time assertion.
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 { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { instrumentCleanupTimer, removeInnerObservers, readProbeSeconds } from './case-inner-timing-fixture.mjs';
const scripts=['case1-accept.sh','case2-unit.sh'];
const read=name=>readFileSync(new URL(`../../../scripts/${name}`,import.meta.url),'utf8');
// The namespace fixes /proc inventory size; it does not replace clean_session.
// See org-cleanup-time-build.md for the standalone and loaded calibration. The
// bound must reject 1s/2s delays and leave most of the 2s production headroom.
// Constant delays below 0.5s can pass; this is not a bound on host-wide scan
// cost. In particular, per-entry work needs a shipped scan-cost follow-up.
const CLEANUP_LIMIT_SECONDS=0.5;
const probe=fileURLToPath(new URL('./case-cleanup-time-fixture.mjs',import.meta.url));
for(const name of scripts)test(`cleanup timing ${name}: observers preserve bytes and reject exit-anchor drift`,()=>{
const source=read(name),clock="/unused/clock '$;literal";
const copy=instrumentCleanupTimer(source,clock);
assert.equal(removeInnerObservers(copy,clock),source);
for(const marker of ['SO_PEERCRED','MOSAIC_TESTER_SOCKET_V1','MOSAIC_LEGACY_STATUS'])
assert.equal(copy.split(marker).length,source.split(marker).length,marker);
const exit=' exit "$rc"\n';
assert.throws(()=>instrumentCleanupTimer(source.replace(exit,''),clock),/anchor/);
assert.throws(()=>instrumentCleanupTimer(source.replace(exit,exit+exit),clock),/anchor/);
});
// Both shipped copies are executed: Case1 shares this supervisor across legacy
// calls, queries and P10; Case2 uses its copy for config queries.
for(const name of scripts)test(`cleanup timing ${name}: inner timeout kills descendants within the cleanup budget`,t=>{
const dir=mkdtempSync(join(tmpdir(),'cl-'));
t.after(()=>rmSync(dir,{recursive:true,force:true}));
// No skip/fallback: this Linux test requires unprivileged user/PID/mount
// namespaces. --kill-child also removes our namespace if the probe times out.
const outer=spawnSync('/usr/bin/unshare',[
'--user','--map-root-user','--pid','--fork','--kill-child=KILL','--mount-proc',
process.execPath,probe,fileURLToPath(new URL(`../../../scripts/${name}`,import.meta.url)),dir,
],{
encoding:'utf8',timeout:15000,killSignal:'SIGKILL',
});
const prerequisite='requires /usr/bin/unshare and unprivileged user/PID/mount namespaces (check kernel/AppArmor/container seccomp)';
assert.equal(outer.error,undefined,`${prerequisite}: ${outer.error?.message}`);
assert.equal(outer.status,0,`${prerequisite}; probe output:\n${outer.stdout}${outer.stderr}`);
assert.equal(outer.stderr,'');
const result=JSON.parse(outer.stdout);
assert.equal(result.error,undefined,result.error);
assert.equal(result.signal,null,result.stderr);
assert.equal(result.status,81,result.stderr);
assert.equal(result.stdout,'');
assert.equal(result.stderr,'');
const pairs=result.pairs;
assert.equal(pairs.length,2,'the command and its live descendant both started');
assert.notEqual(pairs[0][0],pairs[0][1],'the trusted parent leads the command session');
assert.notEqual(pairs[1][0],pairs[0][0]);
assert.equal(pairs[1][1],pairs[0][1],'the descendant belongs to the supervised session');
assert.deepEqual(result.live,[],'cleanup must kill the whole process tree before namespace teardown');
// A positive centisecond pair relies on clean_session's current mandatory
// 0.01s retry sleep. Revisit this measurement if shipped cleanup removes it;
// keep the shared inner-timer parser strict in the meantime.
const seconds=readProbeSeconds(result.clock);
t.diagnostic(`${name}: cleanup ${seconds}s; limit ${CLEANUP_LIMIT_SECONDS}s`);
assert.ok(seconds<CLEANUP_LIMIT_SECONDS,`${name}: cleanup ${seconds}s must be < ${CLEANUP_LIMIT_SECONDS}s`);
});
@@ -0,0 +1,50 @@
// Test-copy observations only: bracket the supervisor's inner timer before
// clean_session scans the host's /proc. Keep all shipped control flow intact.
import { readProbeSeconds } from './case-p10-timing-fixture.mjs';
export { readProbeSeconds };
const BEGIN = '# row253-supervisor-begin\n';
const END = '# row253-supervisor-end\n';
const TIMER = '"$sleep_bin" "$seconds" & timer=$!\n';
const FINISH = 'finish() {\n local rc="$?"\n';
function unique(source, anchor) {
const at=source.indexOf(anchor);
if(at<0 || source.indexOf(anchor,at+anchor.length)>=0) throw new Error('inner observer anchor must occur exactly once');
return at;
}
function innerObservers(clock) {
// The literal path survives env -i in the fake sudo. It is a private scratch
// file, never a product input. Quote even paths containing shell metacharacters.
const file="'"+clock.replaceAll("'", "'\\''")+"'";
return {
begin:`# inner-observer-begin\n{ read -r timing2_up timing2_spare < /proc/uptime &&\n printf 'begin %s\\n' "$timing2_up" >> ${file}; } 2>/dev/null || :\n# inner-observer-begin-end\n`,
end:` # inner-observer-end\n { read -r timing2_up timing2_spare < /proc/uptime &&\n printf 'end %s\\n' "$timing2_up" >> ${file}; } 2>/dev/null || :\n # inner-observer-end-end\n`,
};
}
export function instrumentInnerTimer(source,clock) {
const from=unique(source,BEGIN),to=unique(source,END);
if(to<=from)throw new Error('inner observer scope is reversed');
let supervisor=source.slice(from,to);
unique(supervisor,TIMER);unique(supervisor,FINISH);
const blocks=innerObservers(clock);
supervisor=supervisor.replace(TIMER,blocks.begin+TIMER).replace(FINISH,FINISH+blocks.end);
return source.slice(0,from)+supervisor+source.slice(to);
}
export function removeInnerObservers(source,clock) {
for(const block of Object.values(innerObservers(clock))){unique(source,block);source=source.replace(block,'');}
return source;
}
// Reuse the timing2 finish-entry stamp, moving only the other observation.
// The end is in the supervisor, after clean_session and both waits, immediately
// before exit. Parent scheduling and process start-up never enter this pair.
export function instrumentCleanupTimer(source,clock) {
const blocks=innerObservers(clock);
let observed=instrumentInnerTimer(source,clock);
const from=unique(observed,BEGIN),to=unique(observed,END);
let supervisor=observed.slice(from,to);
const exit=' exit "$rc"\n';
unique(supervisor,exit);
supervisor=supervisor.replace(blocks.begin,'').replace(blocks.end,blocks.begin);
supervisor=supervisor.replace(exit,blocks.end+exit);
return observed.slice(0,from)+supervisor+observed.slice(to);
}
@@ -0,0 +1,33 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, writeFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { join } from 'node:path';
import { tmp } from './helpers.mjs';
import { instrumentInnerTimer, removeInnerObservers, readProbeSeconds } from './case-inner-timing-fixture.mjs';
const scripts=['case1-accept.sh','case2-unit.sh'];
const read=name=>readFileSync(new URL(`../../../scripts/${name}`,import.meta.url),'utf8');
for(const name of scripts)test(`inner timing ${name}: private copy preserves shipped bytes and rejects anchor drift`,t=>{
const clock=join(tmp(t,'timer-'),'clock');
const source=read(name),copy=instrumentInnerTimer(source,clock);
assert.equal(removeInnerObservers(copy,clock),read(name));
for(const marker of ['SO_PEERCRED','MOSAIC_TESTER_SOCKET_V1','MOSAIC_LEGACY_STATUS'])
assert.equal(copy.split(marker).length,source.split(marker).length,marker);
for(const anchor of ['# row253-supervisor-begin\n','# row253-supervisor-end\n','"$sleep_bin" "$seconds" & timer=$!\n','finish() {\n local rc="$?"\n']){
assert.throws(()=>instrumentInnerTimer(source.replace(anchor,''),clock),/anchor/);
assert.throws(()=>instrumentInnerTimer(source.replace(anchor,anchor+anchor),clock),/anchor/);
}
});
for(const name of scripts)test(`inner timing ${name}: real supervisor retains streams status and EXIT cleanup`,t=>{
const clock=join(tmp(t,'timer-'),"clock '$;literal");writeFileSync(clock,'');
const source=instrumentInnerTimer(read(name),clock);
const body=source.slice(source.indexOf('# row253-supervisor-begin'),source.indexOf('# row253-supervisor-end'));
const result=spawnSync('/bin/bash',['-c',body+'\n/bin/bash -c "$TESTER_SUPERVISOR" -- 2 /usr/bin/setsid /usr/bin/sleep /bin/bash -c \'printf exact-out; printf exact-err >&2; /usr/bin/sleep 0.1; exit 7\''],{encoding:'utf8'});
assert.equal(result.status,87,result.stderr);
assert.equal(result.stdout,'exact-out');assert.equal(result.stderr,'exact-err');
assert.match(readFileSync(clock,'utf8'),/^begin [0-9]+\.[0-9]{2}\nend [0-9]+\.[0-9]{2}\n$/);
// The same strict parser used by the timing bands rejects a guard's partial,
// duplicate or reversed pair, rather than substituting wall time.
for(const invalid of ['begin 10.00\n','end 11.00\n','begin 10.00\nend 11.00\nbegin 12.00\nend 13.00\n','begin 11.00\nend 10.00\n'])
assert.throws(()=>readProbeSeconds(invalid));
});
+36 -1
View File
@@ -5,6 +5,13 @@ const LEGACY = '# row276-legacy-begin';
// Stable even when a mutant removes the outer timeout from this command.
const LAUNCH = ' LC_ALL=C ';
const WAIT = ' wait "$child" 2>/dev/null || rc=$?\n';
// org-base-reds: the root pair above also spans supervisor start-up and clean_session, a scan of
// every host /proc entry whose cost grows with the host's process count. The timer pair brackets
// only the inner timer: from just before the supervisor starts its sleep to the start of finish().
const SUPERVISOR = '# row253-supervisor-begin\n';
const SUPERVISOR_END = '# row253-supervisor-end\n';
const TIMER = '"$sleep_bin" "$seconds" & timer=$!\n';
const FINISH = 'finish() {\n local rc="$?"\n';
export const P10_OBSERVERS = Object.freeze({
begin: ` # row299-observer-begin-start
@@ -20,6 +27,26 @@ export const P10_OBSERVERS = Object.freeze({
printf 'end %s\\n' "$row299_up" >> "$ROW299_TIMING_FILE"; } 2>/dev/null || :
fi
# row299-observer-end-end
`,
// Supervisor-side pair. Only the P10 peer's arguments carry the peer-credential option name.
// The pattern is split so this block never adds that literal to the supervisor text: the fake
// sudo stalls any command whose arguments contain it, and the supervisor text is an argument of
// every capture. finish() has no arguments of its own, so its stamp follows exactly one begin
// and is written at most once.
timerBegin: `# row299-timer-begin-start
if [ -n "\${ROW299_TIMER_FILE-}" ] && [[ "$*" == *SO_PEER*CRED* ]]; then
{ read -r row299_up row299_spare < /proc/uptime &&
printf 'begin %s\\n' "$row299_up" >> "$ROW299_TIMER_FILE"; } 2>/dev/null || :
fi
# row299-timer-begin-end
`,
timerEnd: ` # row299-timer-end-start
if [ -n "\${ROW299_TIMER_FILE-}" ]; then
{ read -r row299_up row299_spare < /proc/uptime &&
[[ "$(< "$ROW299_TIMER_FILE")" == begin* && "$(< "$ROW299_TIMER_FILE")" != *end* ]] &&
printf 'end %s\\n' "$row299_up" >> "$ROW299_TIMER_FILE"; } 2>/dev/null || :
fi
# row299-timer-end-end
`,
});
@@ -40,7 +67,15 @@ export function instrumentP10Probe(source) {
uniqueIndex(capture, WAIT);
capture = capture.replace(LAUNCH, P10_OBSERVERS.begin + LAUNCH);
capture = capture.replace(WAIT, WAIT + P10_OBSERVERS.end);
return source.slice(0, start) + capture + source.slice(end);
source = source.slice(0, start) + capture + source.slice(end);
const from = uniqueIndex(source, SUPERVISOR), to = uniqueIndex(source, SUPERVISOR_END);
if (to <= from) throw new Error('P10 observer supervisor scope is reversed');
let supervisor = source.slice(from, to);
uniqueIndex(supervisor, TIMER);
uniqueIndex(supervisor, FINISH);
supervisor = supervisor.replace(TIMER, P10_OBSERVERS.timerBegin + TIMER);
supervisor = supervisor.replace(FINISH, FINISH + P10_OBSERVERS.timerEnd);
return source.slice(0, from) + supervisor + source.slice(to);
}
export function removeP10Observers(source) {
@@ -0,0 +1,34 @@
// No real process is signalled. These hostile kernel-boundary controls expose
// a new session reusing the old SID after the original leader disappears.
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import {spawnSync} from 'node:child_process';
for(const name of ['case1-accept.sh','case2-unit.sh'])for(const mode of ['already-dead','dies-before-signal'])test(`${name}: ${mode} original session never signals a recycled SID`,()=>{
const source=readFileSync(new URL(`../../../scripts/${name}`,import.meta.url),'utf8');
const program=/\/usr\/bin\/python3 -I -S -c '\n([\s\S]*?)\n' "\$child"/.exec(source)?.[1];
assert.ok(program,'the shipped cleanup program exists');
const anchor='\ntry:\n';assert.equal(program.split(anchor).length,2);
const control=`
import atexit, json
from types import SimpleNamespace
attempts = []
checks = 0
atexit.register(lambda: print(json.dumps(attempts)))
def observed(pid):
global checks
if pid == "self/fd/42":
checks += 1
return (b"T", session) if ${mode==='already-dead'?'False':'checks < 3'} else None
return (b"R", session)
def send(fd, sig):
attempts.append([fd, sig])
raise PermissionError("never signal a real task")
fields = observed
os = SimpleNamespace(listdir=lambda path: ["999"], pidfd_open=lambda pid: 17 if pid == session else 99, close=lambda fd: None)
signal = SimpleNamespace(SIGKILL=9, SIGCONT=18, pidfd_send_signal=send)
`;
const r=spawnSync('/usr/bin/python3',['-I','-S','-c',program.replace(anchor,'\n'+control+anchor),'424242','42'],{encoding:'utf8'});
assert.equal(r.status,1,r.stderr);assert.equal(r.stderr,'');
assert.deepEqual(JSON.parse(r.stdout),[],'a stale SID must never authorize a signal');
});
@@ -0,0 +1,85 @@
// Regression controls run the real supervisor. Only the kernel observation is
// fault-injected: a transient empty stat and a delayed first observation of an
// already completed fast command. Neither may authorize an unverified session.
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import {spawnSync} from 'node:child_process';
const names=['case1-accept.sh','case2-unit.sh'];
function body(name){const s=readFileSync(new URL(`../../../scripts/${name}`,import.meta.url),'utf8');return s.slice(s.indexOf('# row253-supervisor-begin'),s.indexOf('# row253-supervisor-end'));}
function replaceOnce(source,anchor,replacement){assert.equal(source.split(anchor).length,2,'fault anchor must be unique');return source.replace(anchor,replacement);}
function run(source,setsid='/usr/bin/setsid',command=['/bin/true']){
return spawnSync('/bin/bash',['-c',source+'\nexec /bin/bash -c "$TESTER_SUPERVISOR" -- 1 "$@"','--',setsid,'/usr/bin/sleep',...command],{encoding:'utf8',timeout:8000});
}
test('shipped supervisors remain byte-identical',()=>assert.equal(body(names[0]),body(names[1])));
for(const name of names){
test(`${name}: a transient empty stat read retries within the inner budget`,()=>{
const source=replaceOnce(body(name),'stat_fields() {','stat_fields() {\n if [ "${empty_once:-0}" = 0 ]; then empty_once=1; return 1; fi');
const r=run(source);assert.equal(r.status,0,r.stderr);assert.equal(r.stdout,'');
});
test(`${name}: fast success cannot disappear before session verification`,()=>{
// Delay only the parent's first observation. BASE lets /bin/true exit and
// loses its proc entry; a stopped trusted launcher remains verifiable.
const source=replaceOnce(body(name),'stat_fields() {','stat_fields() {\n if [ "${delayed_once:-0}" = 0 ]; then delayed_once=1; "$sleep_bin" 0.08; fi');
const r=run(source);assert.equal(r.status,0,r.stderr);
});
test(`${name}: a launcher that never creates a session is refused`,()=>{
const r=run(body(name),'/bin/false');assert.equal(r.status,85,r.stderr);
});
test(`${name}: a stopped launcher without a new session is never accepted`,()=>{
const r=run(body(name),'/usr/bin/env');assert.equal(r.status,81,r.stderr);
});
test(`${name}: persistently unreadable initial stat retains session refusal 85`,()=>{
const source=replaceOnce(body(name),'stat_fields() {','stat_fields() {\n return 1');
const r=run(source);assert.equal(r.status,85,r.stderr);
});
test(`${name}: command status and streams survive the launcher`,()=>{
const r=run(body(name),'/usr/bin/setsid',['/bin/bash','-c','printf out; printf err >&2; exit 7']);
assert.equal(r.status,87,r.stderr);assert.equal(r.stdout,'out');assert.equal(r.stderr,'err');
});
}
// A snapshot of the parent tree would miss a child reparented to namespace init.
// A new session is a separately documented escape, not a session member.
import {mkdtempSync,rmSync} from 'node:fs';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {fileURLToPath} from 'node:url';
for(const name of names)for(const mode of ['reparent','escape','fork','unreadable','unkillable','anchor-death'])test(`${name}: namespace cleanup ${mode}`,t=>{
const dir=mkdtempSync(join(tmpdir(),'sc-'));t.after(()=>rmSync(dir,{recursive:true,force:true}));
const r=spawnSync('/usr/bin/unshare',['--user','--map-root-user','--pid','--fork','--kill-child=KILL','--mount-proc',process.execPath,fileURLToPath(new URL('./case-supervisor-session-fixture.mjs',import.meta.url)),fileURLToPath(new URL(`../../../scripts/${name}`,import.meta.url)),dir,mode],{encoding:'utf8',timeout:12000,killSignal:'SIGKILL'});
assert.equal(r.status,0,r.stderr);assert.equal(r.stderr,'');
const result=JSON.parse(r.stdout);assert.equal(result.error,undefined);assert.equal(result.signal,null);
assert.equal(result.status,['unreadable','unkillable','anchor-death'].includes(mode)?86:81,JSON.stringify(result));
assert.equal(result.stdout,'');
if(mode==='anchor-death')assert.match(result.stderr,/Killed/);else assert.equal(result.stderr,'');
if(!['unreadable','unkillable','anchor-death'].includes(mode))assert.deepEqual(result.sessionLive,[],'no unrecorded live session member may survive');
if(mode==='fork'){assert.ok(result.pairs.length>10);assert.deepEqual(result.live,[],'fast forks must not survive confirmation scans');return;}
assert.equal(result.pairs.length,2);
const [leader,descendant]=result.pairs;
assert.notEqual(leader[0],leader[1]);
assert.equal(result.anchor,leader[1],'the trusted command parent reserves the SID');
if(mode==='escape'){
assert.equal(descendant[0],descendant[1]);assert.notEqual(descendant[1],leader[1]);
assert.deepEqual(result.live,[descendant[0]],'recorded gap: a new session survives; namespace fixture removes it');
}else{
assert.equal(descendant[1],leader[1]);
if(mode==='anchor-death'){assert.ok(result.live.includes(leader[0]),'lost anchor refuses before signalling a bare SID');}
else if(mode==='reparent'){assert.equal(result.parent,1,'the intermediate parent exited before cleanup');assert.deepEqual(result.live,[],'reparenting must not hide a session member');}
else assert.ok(result.live.includes(descendant[0]),'the refusal control leaves a live member before fixture cleanup');
}
});
// Unlike the private-inventory timing test, this sees the real host /proc.
// Restoring the old per-entry bash read/parse scan exceeds this bound on a
// representative host. It uses cleanup-time's stamps, excluding the inner wait.
import {writeFileSync} from 'node:fs';
import {instrumentCleanupTimer,readProbeSeconds} from './case-inner-timing-fixture.mjs';
for(const name of names)test(`${name}: host inventory cleanup stays within 0.5 seconds`,t=>{
const dir=mkdtempSync(join(tmpdir(),'hc-'));t.after(()=>rmSync(dir,{recursive:true,force:true}));
const clock=join(dir,'clock');writeFileSync(clock,'');
const r=run(instrumentCleanupTimer(body(name)+'# row253-supervisor-end\n',clock),'/usr/bin/setsid',['/bin/bash','-c','/usr/bin/sleep 30 & wait']);
assert.equal(r.status,81,r.stderr);assert.equal(r.stderr,'');assert.equal(r.stdout,'');
const seconds=readProbeSeconds(readFileSync(clock,'utf8'));t.diagnostic(`${name}: host cleanup ${seconds}s`);
assert.ok(seconds<0.5,`host cleanup ${seconds}s must be < 0.5s`);
});
@@ -0,0 +1,58 @@
// Namespace-local evidence and cleanup. The JSON observation precedes teardown.
import {readFileSync,writeFileSync,readdirSync,openSync,closeSync} from 'node:fs';
import {spawnSync} from 'node:child_process';
const [script,dir,mode]=process.argv.slice(2),records=dir+'/pids',worker=dir+'/worker.mjs';
const source=readFileSync(script,'utf8');
let body=source.slice(source.indexOf('# row253-supervisor-begin'),source.indexOf('# row253-supervisor-end'));
// Adversarial kernel boundary controls keep the real cleanup loop and budget.
if(mode==='unreadable')body=body.replace('def fields(pid):','def fields(pid):\n raise PermissionError("injected unreadable stat")');
if(mode==='unkillable')body=body.replace('signal.pidfd_send_signal(fd, signal.SIGKILL)','None # injected successful kill with a surviving member');
if(mode==='fork')writeFileSync(worker,`
import {spawn} from 'node:child_process';
import {appendFileSync,readFileSync} from 'node:fs';
const record=${JSON.stringify(records)};
const sid=p=>Number(readFileSync('/proc/'+p+'/stat','utf8').split(') ').at(-1).split(' ')[3]);
appendFileSync(record,JSON.stringify([process.pid,sid(process.pid)])+'\\n');
setInterval(()=>{
const child=spawn('/usr/bin/sleep',['30'],{stdio:'ignore'});
appendFileSync(record,JSON.stringify([child.pid,sid(child.pid)])+'\\n');
},4);
`);
else writeFileSync(worker,`
import {spawn} from 'node:child_process';
import {readFileSync,writeFileSync} from 'node:fs';
const record=${JSON.stringify(records)};
const child=spawn('/bin/bash',['-c',${JSON.stringify(mode==='escape'?'exec /usr/bin/setsid /usr/bin/sleep 30':'/usr/bin/sleep 30 & echo $! > "$1"')},'--',record+'.child'],{stdio:'ignore'});
setTimeout(()=>{
const pid=${mode==='escape'?'child.pid':"Number(readFileSync(record+'.child','utf8').trim())"};
const sid=p=>Number(readFileSync('/proc/'+p+'/stat','utf8').split(') ').at(-1).split(' ')[3]);
writeFileSync(record,JSON.stringify([[process.pid,sid(process.pid)],[pid,sid(pid)]]));
writeFileSync(record+'.anchor',String(process.ppid));
writeFileSync(record+'.parent',readFileSync('/proc/'+pid+'/stat','utf8').split(') ').at(-1).split(' ')[1]);
if(${JSON.stringify(mode)} === "anchor-death")process.kill(process.ppid,"SIGKILL");
},100);
setInterval(()=>{},1000);
`);
const out=openSync(dir+'/out','w'),err=openSync(dir+'/err','w');
const result=spawnSync('/bin/bash',['-c',body+'\nexec /bin/bash -c "$TESTER_SUPERVISOR" -- 1 /usr/bin/setsid /usr/bin/sleep "$@"','--','/bin/bash','-c','exec "$@" >/dev/null 2>&1','--',process.execPath,worker],{encoding:'utf8',stdio:['ignore',out,err],timeout:8000,killSignal:'SIGKILL'});
closeSync(out);closeSync(err);
result.stdout=readFileSync(dir+'/out','utf8');result.stderr=readFileSync(dir+'/err','utf8');
let pairs=[];
try{const text=readFileSync(records,'utf8');pairs=mode==='fork'?text.trim().split('\n').map(JSON.parse):JSON.parse(text);}catch{}
const live=[];
for(const [pid] of pairs){try{const state=readFileSync('/proc/'+pid+'/stat','utf8').split(') ').at(-1).split(' ')[0];if(!['Z','X'].includes(state))live.push(pid);}catch{}}
// Inventory the entire private namespace too: a fork between spawn and its
// record write must not escape the assertion just because it was unrecorded.
const sessionLive=[];
for(const pid of readdirSync('/proc').filter(p=>/^[0-9]+$/.test(p))){
try{
const words=readFileSync('/proc/'+pid+'/stat','utf8').split(') ').at(-1).split(' ');
if(Number(words[3])===pairs[0]?.[1]&&!['Z','X'].includes(words[0]))sessionLive.push(Number(pid));
}catch{}
}
let parent,anchor;
try{anchor=Number(readFileSync(records+".anchor","utf8"));}catch{}
try{parent=Number(readFileSync(records+'.parent','utf8'));}catch{}
process.stdout.write(JSON.stringify({sessionLive,parent,anchor,status:result.status,signal:result.signal,error:result.error?.message,stdout:result.stdout,stderr:result.stderr,pairs,live}));
// These PIDs exist only in the private namespace. Never signal them on the host.
for(const [pid] of pairs)try{process.kill(pid,'SIGKILL');}catch{}
+60 -11
View File
@@ -13,6 +13,7 @@ import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { tmp } from "./helpers.mjs";
import { instrumentP10Probe, removeP10Observers, readProbeSeconds, P10_OBSERVERS } from "./case-p10-timing-fixture.mjs";
import { instrumentInnerTimer } from "./case-inner-timing-fixture.mjs";
import { CASE_SUDO_SESSION, recordedLivePids, killRecordedPids, fixtureGuard, cleanupFixture, pollFixtureGone, blockingQuerySource, LEGACY_CASES } from "./case-sudo-fixture.mjs";
const REPO = fileURLToPath(new URL("../../../", import.meta.url));
@@ -378,6 +379,9 @@ test('row299 P10 timing copy preserves shipped bytes and isolates observer varia
assert.deepEqual([...block.matchAll(/\b([A-Za-z_][A-Za-z0-9_]*)=/g)],[],'no other assignments in observer blocks');
assert.doesNotMatch(block,/\$ROOT|\$\{ROOT/,'fixture root cannot supply the clock');
}
// The fake sudo keys its faults on these argument markers; observers must not add or remove any.
for(const marker of['SO_PEERCRED','MOSAIC_TESTER_SOCKET_V1','MOSAIC_LEGACY_STATUS'])
assert.equal(copy.split(marker).length,source.split(marker).length,`observers keep the ${marker} count`);
const changedTimer=source.replace('tester_capture 2 4 128','tester_capture 1 4 128');
assert.equal(removeP10Observers(instrumentP10Probe(changedTimer)),changedTimer,'copy keeps current timer mutants');
});
@@ -402,6 +406,34 @@ capture`;
assert.match(read(clock),/^begin [0-9]+\.[0-9]{2}\nend [0-9]+\.[0-9]{2}\n$/);
});
test('row299 P10 timer observers keep supervisor status and record one P10 pair',t=>{
const dir=tmp(t,'p10-timer-'),clock=join(dir,'clock'),trapFile=join(dir,'trap');
put(clock,'',0o600);
// Same shape as TESTER_SUPERVISOR: positional arguments are the setsid command, finish() has none.
const program=`set +m
finish() {
local rc="$?"
${P10_OBSERVERS.timerEnd} printf trap > "$ROW299_TRAP_FILE"
exit "$rc"
}
trap finish EXIT
${P10_OBSERVERS.timerBegin}printf stdout-without-newline
printf stderr-without-newline >&2
exit 81`;
const supervise=(...args)=>spawnSync('/bin/bash',['-c',program,'--',...args],{encoding:'utf8',env:{...process.env,ROW299_TIMER_FILE:clock,ROW299_TRAP_FILE:trapFile}});
let r=supervise('node','-e','query');
assert.equal(r.status,81);assert.equal(read(clock),'','a supervisor without the P10 peer records nothing');
r=supervise('python','-c','getsockopt(SO_PEERCRED)');
assert.equal(r.status,81);
assert.equal(r.stdout,'stdout-without-newline');
assert.equal(r.stderr,'stderr-without-newline');
assert.equal(read(trapFile),'trap');
assert.match(read(clock),/^begin [0-9]+\.[0-9]{2}\nend [0-9]+\.[0-9]{2}\n$/);
const pair=read(clock);
r=supervise('node','-e','later');
assert.equal(r.status,81);assert.equal(read(clock),pair,'a later supervisor cannot add a second end');
});
test('row299 P10 observer records are complete and ordered',()=>{
assert.equal(readProbeSeconds('begin 100.01\nend 102.03\n'),2.02);
const invalid={
@@ -433,23 +465,33 @@ for(const fault of ['inner','outer','overflow'])test(`row253 C4 P10 ${fault} rea
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');}
let observedScript=SCRIPT,timingFile;
let observedScript=SCRIPT,timingFile,timerFile;
if(fault==='inner'||fault==='outer'){
observedScript=join(h.dir,'case1-p10-observed.sh');timingFile=join(h.hc,'probe-uptime');
put(timingFile,'',0o600);put(observedScript,instrumentP10Probe(read(SCRIPT)),0o700);
// The outer fault stalls before the supervisor starts, so only the inner fault has a timer pair.
if(fault==='inner'){timerFile=join(h.hc,'timer-uptime');put(timerFile,'',0o600);}
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();
const observe={...(timingFile?{ROW299_TIMING_FILE:timingFile}:{}),...(timerFile?{ROW299_TIMER_FILE:timerFile}:{})};
const start=performance.now(),r=run(h,observe,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';
assert.match(r.out,new RegExp('P10 tester-socket-squat unnamed boundary=failed reason=probe-'+reason+' '));
assert.doesNotMatch(r.out,/boundary=observed/);
let probeSeconds=seconds;
let probeSeconds=seconds,timerSeconds;
if(timingFile)assert.doesNotThrow(()=>{probeSeconds=readProbeSeconds(read(timingFile));},'P10 observer pair is required');
if(fault==='inner'||fault==='outer')t.diagnostic(JSON.stringify({fault,mode,baselineSeconds,faultSeconds:seconds,probeSeconds}));
if(fault==='inner')assert.ok(probeSeconds>=1.25&&probeSeconds<2.75,`${mode}: direct probe ${probeSeconds}s; baseline ${baselineSeconds}s is diagnostic only`);
if(timerFile)assert.doesNotThrow(()=>{timerSeconds=readProbeSeconds(read(timerFile));},'P10 timer observer pair is required');
if(fault==='inner'||fault==='outer')t.diagnostic(JSON.stringify({fault,mode,baselineSeconds,faultSeconds:seconds,probeSeconds,timerSeconds}));
// org-base-reds: the band applies to the inner timer itself. The root pair adds supervisor start-up and
// clean_session's whole-host /proc scan, which grows with the host's process count, so it must contain
// the timer pair but has no band of its own; probe-inner-timeout above already places it before the outer bound.
if(fault==='inner'){
assert.ok(timerSeconds>=1.25&&timerSeconds<2.75,`${mode}: inner timer ${timerSeconds}s (root probe ${probeSeconds}s); baseline ${baselineSeconds}s is diagnostic only`);
assert.ok(probeSeconds>=timerSeconds,`${mode}: root probe ${probeSeconds}s must contain the inner timer ${timerSeconds}s`);
}
if(fault==='outer'){
assert.ok(probeSeconds>=3.25&&probeSeconds<4.75,`${mode}: direct probe ${probeSeconds}s; baseline ${baselineSeconds}s is diagnostic only`);
if(mode==='hop')assert.ok(recordedLivePids(files[0]).length>0,'stalled supervisor is R3 residual');
@@ -896,7 +938,7 @@ test("row 195 accept: every in-instance call goes through sudo -u mosaic -H env
// mos-1326: assert every argv element, not flattened/substr command detection.
const source=read(SCRIPT);
const supervisor=source.split("<<'MOSAIC_TESTER_SUPERVISOR'")[1].split('\n').slice(1).join('\n').split('\nMOSAIC_TESTER_SUPERVISOR')[0]+'\n';
assert.equal(createHash('sha256').update(supervisor).digest('hex'),'34063098d7edb9067c11456165dc7535bc0e779d053c43599884d6b614528639');
assert.equal(createHash('sha256').update(supervisor).digest('hex'),'52509773fa2bb0701402b8d2454816153e18323cfaa75cb72418b46c4dbb2d34');
const runner=/LEGACY_COMMAND_RUNNER='([\s\S]*?)'\nlegacy_display_rc/.exec(source)[1];
const f7=/F7_JS='([\s\S]*?)'\ncheck_f7/.exec(source)[1];
const raw=read(join(h.hc,'sudo-argv.jsonl')).trim().split('\n').map(line=>JSON.parse(line));
@@ -927,8 +969,8 @@ test("row 195 accept: the host check runs with --check and never --enforce", (t)
// Row276 exercises the real legacy launcher with fixture-only commands, never sudo.
function legacyBody() {
const source=read(SCRIPT),start=source.indexOf('# row253-supervisor-begin'),end=source.indexOf('# row276-legacy-end');
function legacyBody(source=read(SCRIPT)) {
const start=source.indexOf('# row253-supervisor-begin'),end=source.indexOf('# row276-legacy-end');
assert.ok(start>=0&&end>start,'legacy supervisor block exists');
return source.slice(start,end);
}
@@ -950,11 +992,18 @@ for(const {tag} of LEGACY_CASES)test(`row276 ${tag} legacy inner timeout cleans
const h=host(t),records=join(h.hc,'legacy-pids'),files=[join(h.hc,'session-pids'),records];
put(join(h.hc,'session-mode'),mode);
const file=join(h.dir,'blocked.mjs');put(file,blockingQuerySource(records,'signals')+'\nloadSystem();');
const capture=cleanupFixture(t,files),stop=fixtureGuard(t,files,4500);
const start=performance.now(),r=legacyCall(h,tag,[NODE,file]);capture();
const clock=join(h.dir,'inner-clock');put(clock,'',0o600);
const body=legacyBody(instrumentInnerTimer(read(SCRIPT),clock));
const capture=cleanupFixture(t,files),stop=fixtureGuard(t,files,4500,clock);
// The outer watchdog includes startup and cleanup; only the supervisor pair
// measures the inner budget. Seven seconds also lets the 4.5s guard expose a missing timer.
const start=performance.now(),r=legacyCall(h,tag,[NODE,file],{outer:7,body});capture();
assert.equal(r.status,81,r.stdout+r.stderr);
assert.equal(r.stderr,`case1-accept: ${tag} inner-timeout\n`);
assert.ok((performance.now()-start)/1000<2,mode);
const seconds=readProbeSeconds(read(clock)),wall=(performance.now()-start)/1000;
assert.ok(seconds>=0.25&&seconds<1.25,`${tag}/${mode}: inner timer ${seconds}s (wall ${wall}s)`);
assert.ok(wall>=seconds,`${mode}: the call contains its timer interval`);
t.diagnostic(`${tag}/${mode}: inner ${seconds}s; wall ${wall}s`);
assert.deepEqual(await pollFixtureGone(files),[],mode);await stop();
}
});
+53 -22
View File
@@ -14,6 +14,7 @@ import { fileURLToPath } from "node:url";
import { businessDoc, systemConfig, writeJson } from "../../business/tests/helpers.mjs";
import { binds, prepareRun, sandboxArgv, sandboxProblem } from "../../seat/src/sandbox.mjs";
import { tmp } from "./helpers.mjs";
import { instrumentInnerTimer, readProbeSeconds } from "./case-inner-timing-fixture.mjs";
import { CASE_SUDO_SESSION, recordedLivePids, killRecordedPids, fixtureGuard, cleanupFixture, pollFixtureGone, blockingQuerySource } from "./case-sudo-fixture.mjs";
const REPO = fileURLToPath(new URL("../../../", import.meta.url));
@@ -107,13 +108,13 @@ const libraryFlags = (h) => ["--port", "443", "--business", "acme", "--public-ho
"--allow-user", "melanie", "--proof-header", "X-Proxy-Proof", "--proof-file", h.proof];
const factoryFlags = () => ["--port", "443", "--business", "acme", "--tester-socket"];
function unit(h, { profile = "library", node = USR_NODE, copy = COPY_A, extra = [], flags, env = {}, outer = ["--business", "acme"], argv } = {}) {
function unit(h, { profile = "library", node = USR_NODE, copy = COPY_A, extra = [], flags, env = {}, outer = ["--business", "acme"], argv, script: unitScript = UNIT } = {}) {
const serve = flags ?? (profile === "factory" ? factoryFlags() : libraryFlags(h));
const args = argv ?? ["--user", "mosaic", "--home", h.home, ...outer, "--copy", copy, "--node", node, "--profile", profile, ...extra, "--", ...serve];
const r = spawnSync(UNIT, args, {
const r = spawnSync(unitScript, args, {
encoding: "utf8",
timeout: 30000,
env: { PATH: `${h.bin}:${process.env.PATH}`, CASE2_UNIT_DIR: h.units, CASE2_SYSTEMCTL: join(h.bin, "systemctl"), CASE2_GETENT: join(h.bin, "getent"), ...env },
env: { HOME: process.env.HOME, TMPDIR: process.env.TMPDIR, LANG: process.env.LANG, PATH: `${h.bin}:${process.env.PATH}`, CASE2_UNIT_DIR: h.units, CASE2_SYSTEMCTL: join(h.bin, "systemctl"), CASE2_GETENT: join(h.bin, "getent"), ...env },
});
return { code: r.status, out: r.stdout, err: r.stderr };
}
@@ -197,12 +198,27 @@ for(const kind of ['sleep','signals','member']) test(`row253 C4 ${kind} query ha
const member=join(h.root,'q ) '+String.fromCharCode(10)+'mem'),proof=join(h.root,'comm-proof');
if(kind==='member')symlinkSync('/usr/bin/sleep',member);
writeFileSync(join(copyDir(h),'packages/cli/src/config.mjs'),blockingQuerySource(queries,kind,member,proof));
const capture=cleanupFixture(t,files);const stopGuard=fixtureGuard(t,files);
const start=performance.now(),r=unit(h,{profile:'factory'}),seconds=(performance.now()-start)/1000;capture();
const clock=join(h.root,'inner-clock'),observed=join(h.root,'observed/scripts/case2-unit.sh');
mkdirSync(dirname(observed),{recursive:true});
symlinkSync(join(REPO,'scripts/selector.sh'),join(dirname(observed),'selector.sh'));
writeFileSync(clock,'',{mode:0o600});
// The 7s outer watchdog spans startup and cleanup too. Give the private copy
// cleanup room while retaining a separate band on the unchanged 5s inner timer.
const source=read(UNIT),call='query="$(tester_capture 5 7 4096';
assert.equal(source.split(call).length,2,'tester query budget anchor is unique');
script(observed,instrumentInnerTimer(source.replace(call,call.replace(' 5 7 ',' 5 15 ')),clock));
const capture=cleanupFixture(t,files);const stopGuard=fixtureGuard(t,files,9500,clock);
const start=performance.now(),r=unit(h,{profile:'factory',script:observed,env:{
CASE2_BUS_TEMPLATE:BUS_TEMPLATE,CASE2_WEBUI_TEMPLATE:WEBUI_TEMPLATE,
CASE2_PROXY_TEMPLATE:join(REPO,'packages/cli/systemd/mosaic-proxy.service.in'),
}}),wall=(performance.now()-start)/1000;capture();
refuses(r,/^case2-unit: tester config query inner-timeout\n$/);
const seconds=readProbeSeconds(read(clock));
const [queryPid,querySid]=read(queries).trim().split('\n')[0].trim().split(/\s+/).map(Number);
assert.equal(queryPid,querySid,`${kind}/${mode}: direct query PID must equal its kernel SID`);
assert.ok(seconds<7,`${kind}/${mode}: ${seconds}s exceeds outer bound`);
assert.notEqual(queryPid,querySid,`${kind}/${mode}: query runs below the trusted session leader`);
assert.ok(seconds>=4.25&&seconds<5.75,`${kind}/${mode}: inner timer ${seconds}s (wall ${wall}s)`);
assert.ok(wall>=seconds,`${mode}: the call contains its timer interval`);
t.diagnostic(`${kind}/${mode}: inner ${seconds}s; wall ${wall}s`);
assert.deepEqual(await pollFixtureGone(files),[],mode);
if(kind==='member')assert.ok(read(proof).includes('('+member.split('/').at(-1)+') '),'member comm with newline and closing parenthesis was observed');
assert.equal(existsSync(h.log),false,mode);await stopGuard();
@@ -240,24 +256,39 @@ test('row253 C4 inline supervisor is byte-identical in both host scripts',()=>{
});
test('row253 C4 cleanup budget starts after inventory pass one',()=>{
// A deterministic clock models a slow inventory without host-load timing.
// All process reads and signals are stand-ins; no real PID is signalled.
// Run the shipped cleanup with a deterministic slow inventory/clock. Every
// kernel operation is replaced before execution; no real PID is signalled.
const source=read(UNIT);
const body=source.slice(source.indexOf('stat_fields() {'),source.indexOf('finish() {'));
const program=/\/usr\/bin\/python3 -I -S -c '\n([\s\S]*?)\n' "\$child"/.exec(source)?.[1];
assert.ok(program,'fixed cleanup program exists');
const anchor='\ntry:\n';
assert.equal(program.split(anchor).length,2,'cleanup loop anchor is unique');
const standins=`
child=424242; sleep_bin=:; seen=0; clocks=0; kills=0
stat_fields() {
seen=$((seen+1)); ST_STATE=R; ST_SESSION=0
[ "$seen" != 1 ] || ST_SESSION="$child"
return 0
}
clock_cs() { clocks=$((clocks+1)); CLOCK_CS=$((clocks*1000)); }
kill() { kills=$((kills+1)); return 0; }
clean_session; rc=$?
printf '%s' "$kills"
exit "$rc"
from types import SimpleNamespace
alive = True
kills = 0
clock = time.monotonic()
def inventory(path):
global clock
clock += 1000
return ["999"]
def fake_fields(pid):
return (b"T", session) if pid == "self/fd/42" else ((b"R", session) if alive else None)
def send(fd, sig):
global alive, kills
if sig == 9:
alive = False
kills += 1
fields = fake_fields
os = SimpleNamespace(listdir=inventory, pidfd_open=lambda pid: 17, close=lambda fd: None)
signal = SimpleNamespace(SIGKILL=9, SIGCONT=18, pidfd_send_signal=send)
time = SimpleNamespace(monotonic=lambda: clock, sleep=lambda seconds: None)
`;
const r=spawnSync('/bin/bash',['-c',body+standins],{encoding:'utf8'});
const r=spawnSync('/usr/bin/python3',['-I','-S','-c',program.replace(anchor,standins+'\n'+anchor)+'\nprint(kills,end="")','424242','42'],{encoding:'utf8'});
assert.equal(r.status,0,r.stderr);assert.equal(r.stdout,'1');
});
+15 -1
View File
@@ -804,13 +804,27 @@ test("land: a failed gate is refused gate-failed with its step, told to the role
assert.equal(ticket, TICKET);
assert.equal(a.code, "gate-failed");
assert.equal(a.notify, true);
assert.match(a.detail, /^pkg-bus failed, see \/.*\/bus-host\/land\/land-[0-9a-f]{12}\/pkg-bus\.log$/);
assert.match(a.detail, /^pkg-bus failed, see land\/land-[0-9a-f]{12}\/pkg-bus\.log in the bus host directory$/);
assert.equal(w.git("rev-parse", "refs/heads/main"), w.main);
assert.equal(w.git("cat-file", "-t", r.commit), "commit", "the squash was made, and only main didn't move");
assert.deepEqual(w.of("recordLand"), []);
assert.equal(w.trees(), 0);
});
// PR #1916 (org-redact5): the detail reaches the role, so it never carries the log's host
// path; a log file name outside the id pattern is dropped too.
test("land: a failed gate's detail names the land directory, never the log's host path", async (t) => {
const SENTINEL = "sk-test-NOT-A-SECRET-1234";
let log = null;
const w = await landSetup(t, { gate: { run: async ({ logs }) => ({ ok: false, step: "pkg-bus", log: (log = join(logs, SENTINEL, `${SENTINEL}.log`)) }) } });
await w.land();
await w.ended();
const [[, a]] = w.of("landRefused");
assert.equal(a.code, "gate-failed");
assert.match(a.detail, /^pkg-bus failed, see land\/land-[0-9a-f]{12} in the bus host directory$/);
assert.ok(!a.detail.includes(SENTINEL) && !a.detail.includes(log) && !a.detail.includes(w.f.dataRoot), a.detail);
});
test("land: a record the broker refuses moves main back, and the landing is refused with the broker's code", async (t) => {
const w = await landSetup(t);
w.state.recordFails = "decision-consumed";
+8 -2
View File
@@ -315,7 +315,7 @@ test("a host.lock that goes between the check and the read is tried again; one t
assert.deepEqual(readdirSync(hostDir(dataRoot)), ["host.lock"]);
writeFileSync(hostLock(dataRoot), "{not json", { mode: 0o600 });
assert.throws(() => claimHostLock(dataRoot, mine), (e) => e.exitCode === 3 && e.message === `bus host state is unreadable: ${hostLock(dataRoot)}`);
assert.throws(() => claimHostLock(dataRoot, mine), (e) => e.exitCode === 3 && e.message === "bus host state is unreadable");
assert.equal(readFileSync(hostLock(dataRoot), "utf8"), "{not json");
assert.deepEqual(readdirSync(hostDir(dataRoot)), ["host.lock"]);
});
@@ -749,8 +749,14 @@ test("a stale organization writer lock refuses start and status without removing
});
test("organization bus start refuses without a notifier config", async t => {
// PR #1916 (org-redact5): the refusal names the log inside the organization's
// runtime directory, never by host path. (A sentinel directory in the root would
// push the bus socket path past the socket length limit, so the check is on the
// fixture's own roots.)
const f = await orgFixture(t, { notify: false });
await assert.rejects(busStart(f.ctx, "acme", { detach: true }), /failed to start/i);
await assert.rejects(busStart(f.ctx, "acme", { detach: true }), (e) => e.exitCode === 3
&& e.message === "bus failed to start; see bus-host/host.log in the runtime directory of organization acme"
&& !e.message.includes(f.dataRoot) && !e.message.includes(f.instanceRoot));
assert.match(readFileSync(join(f.dataRoot, "bus-host", "host.log"), "utf8"), /no notifier config/);
assert.equal(existsSync(join(f.dataRoot, "bus", "writer.lock")), false);
});
+9 -4
View File
@@ -21,10 +21,15 @@ function fixture(t) {
};
return { root, file, env, run, boot };
}
function redacted(result, file, status) {
// label: the CLI refusal names the file by this label and never by host path (PR #1916).
// Without it, the refusal comes from scripts/mosaic-config.mjs, which still names the file.
function redacted(result, file, status, label) {
assert.equal(result.status, status, result.stderr);
assert.equal(result.stdout, '');
assert.ok(result.stderr.includes(file), result.stderr);
if (label) {
assert.ok(!result.stderr.includes(file), result.stderr);
assert.ok(result.stderr.includes(label), result.stderr);
} else assert.ok(result.stderr.includes(file), result.stderr);
assert.match(result.stderr, /not valid JSON/);
assert.doesNotMatch(result.stderr, /sk-test|Unexpected token/);
}
@@ -40,7 +45,7 @@ test('system config parse refusal and CLI relay omit parser bytes', t => {
assert.doesNotMatch(error.message, /sk-test|Unexpected token/);
return true;
});
redacted(f.run(cli, 'org', 'config', 'validate', 'alpha'), f.file, 3);
redacted(f.run(cli, 'org', 'config', 'validate', 'alpha'), f.file, 3, 'configuration is not valid JSON; expected readable JSON: the system config MOSAIC_CONFIG names');
});
test('named instance marker parse refusal omits parser bytes', t => {
@@ -55,5 +60,5 @@ for (const operation of ['validate', 'create', 'update']) test(`org ${operation}
const file = join(f.root, 'alpha.json'); writeFileSync(file, secret);
const args = operation === 'validate' ? ['validate', file]
: [operation, 'alpha', '--from', file, ...(operation === 'update' ? ['--if-revision', '1'] : [])];
redacted(f.run(cli, 'org', ...args), file, 2);
redacted(f.run(cli, 'org', ...args), file, 2, 'mosaic: cannot read organization document: not valid JSON\n');
});
+3 -3
View File
@@ -46,14 +46,14 @@ for (const [field, edit] of configCases) test(`config ${field} omits the configu
test('config-command refuses system config without relaying stderr file values', t => {
const root = scratch(t), f = fixture(root), d = JSON.parse(readFileSync(f.config, 'utf8')); d.execution.model = secret + '\n'; writeFileSync(f.config, JSON.stringify(d));
let err = ''; const status = runConfigCommand(['validate', 'acme'], { env: f.env, stdout: { write() {} }, stderr: { write(s) { err += s; } } });
assert.equal(status, 3); assert.match(err, /system config problem \(mosaic-config exit 2\)/); assert.ok(err.includes(f.config), err); assert.doesNotMatch(err, /sk-test|NOT-A-SECRET/); assert.match(err, /execution.model/); assert.match(err, /expected a 1-200 character name/);
assert.equal(status, 3); assert.match(err, /system config problem \(mosaic-config exit 2\)/); assert.ok(!err.includes(f.config), err); assert.match(err, /the system config MOSAIC_CONFIG names/); assert.doesNotMatch(err, /sk-test|NOT-A-SECRET/); assert.match(err, /execution.model/); assert.match(err, /expected a 1-200 character name/);
});
test('project id mismatch omits the file value in config-command', t => {
const root = scratch(t), f = fixture(root), file = join(f.doc.projects.stack.root, '.mosaic', 'project.json');
mkdirSync(join(f.doc.projects.stack.root, '.mosaic'));
writeFileSync(file, JSON.stringify({ projectVersion: 1, id: secret.toLowerCase() }));
let err = ''; const status = runConfigCommand(['validate', 'acme'], { env: f.env, stdout: { write() {} }, stderr: { write(s) { err += s; } } });
assert.equal(status, 2); assert.ok(err.includes(file), err); assert.match(err, /id/); assert.doesNotMatch(err, /sk-test|NOT-A-SECRET/);
assert.equal(status, 2); assert.ok(!err.includes(file), err); assert.match(err, /project file of project stack has id that does not match the declared project key/); assert.doesNotMatch(err, /sk-test|NOT-A-SECRET/);
});
for (const [field, edit] of [
['dataRoot', d => { d.dataRoot = `/x/${secret}/../y`; }],
@@ -80,7 +80,7 @@ cp.spawnSync = () => ({ status: 2, stderr: ${JSON.stringify(childStderr)}, stdou
const { runConfigCommand } = await import(${JSON.stringify(new URL('../../organization/src/config-command.mjs', import.meta.url).href)});
process.exitCode = runConfigCommand(['validate', 'acme'], { env: { MOSAIC_CONFIG: ${JSON.stringify(file)} } });`;
const r = spawnSync(process.execPath, ['--input-type=module', '-e', code], { encoding: 'utf8', env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
assert.equal(r.status, 3, r.stderr); assert.ok(r.stderr.includes(file), r.stderr); assert.doesNotMatch(r.stderr, /sk-test|NOT-A-SECRET/);
assert.equal(r.status, 3, r.stderr); assert.ok(!r.stderr.includes(file), r.stderr); assert.match(r.stderr, /: the system config MOSAIC_CONFIG names\n$/); assert.doesNotMatch(r.stderr, /sk-test|NOT-A-SECRET/);
});
import { bootConfig, loadSystem } from '../src/config.mjs';
+16 -22
View File
@@ -1,3 +1,5 @@
import {ownerConsole} from '../../bus/tests/hire-fixtures.mjs';
import {readTemplate} from '../../organization/src/definitions.mjs';
import { requestHostOps } from './request-host-ops.mjs';
// Row 194 (#1692, D1.1, T2-T5, V5): the launcher half of the lifecycle verbs. The broker records
// the human's launch, end, regenerate and delete as requests (bus lifecycle-verbs.test.mjs), and
@@ -238,7 +240,7 @@ function bind(s, role, run) {
s.runtime.broker.request(cap, { verb: "role.claim" });
return cap;
}
const fire = (s, chief, name) => s.runtime.broker.request(chief, { verb: "instance.fire", args: { name, reason: "the review is done" } });
const fire = (s, chief, name) => s.runtime.broker.request(chief, {verb:"hire.raise",args:{op:"fire",level:"org",name,reason:"the review is done"}}).then(d => s.runtime.broker.request(ownerConsole(s.runtime.broker,"acme"),{verb:"hire.resolve.console",args:{id:d.id,choice:"yes",digest:d.hire.digest}}));
const allowed = (p, action) => {
const db = new DatabaseSync(join(p.f.dataRoot, "bus", "bus.sqlite"), { readOnly: true });
@@ -601,7 +603,7 @@ test("row 194 T9a: fire signals its bound run and a spawned but unbound registry
const spare = await standIn(t, p, s, { instance: "coder", run: "coder-spare" });
const stuck = await standIn(t, p, s, { instance: "coder", run: "coder-stuck", ignoreTerm: true });
bind(s, "coder", "coder-run");
const fired = fire(s, bind(s, "chief", "chief-run"), "coder");
const fired = await fire(s, bind(s, "chief", "chief-run"), "coder");
// The bus knows only the bound run. The pass takes the spawned ones from the registry.
assert.deepEqual(fired.runs, ["coder-run"]);
await s.launcher.tick();
@@ -629,7 +631,7 @@ test("row 194 T9a: fire of a registry run with no runtime record reads lifecycle
const bound = await standIn(t, p, s, { instance: "coder", run: "coder-run" });
const ghost = await standIn(t, p, s, { instance: "coder", run: "coder-ghost", managed: false });
bind(s, "coder", "coder-run");
const fired = fire(s, bind(s, "chief", "chief-run"), "coder");
const fired = await fire(s, bind(s, "chief", "chief-run"), "coder");
assert.deepEqual(fired.runs, ["coder-run"]);
await s.launcher.tick();
assert.equal(await bound.exited, 143);
@@ -685,7 +687,7 @@ test("row 194 T9a: a runner whose identity changed before the SIGTERM gets no si
const between = await standIn(t, p, s, { instance: "coder", run: "coder-between", ignoreTerm: true });
Object.assign(pids, { before: before.pid, between: between.pid });
bind(s, "coder", "coder-between");
const fired = fire(s, bind(s, "chief", "chief-run"), "coder");
const fired = await fire(s, bind(s, "chief", "chief-run"), "coder");
await s.launcher.tick();
assert.deepEqual(sent, [["coder-between", "SIGTERM"]]);
assert.deepEqual([before.gone(), between.gone(), before.terms(), between.terms()], [false, false, 0, 1]);
@@ -719,7 +721,7 @@ test("row 194 T9b: a halt during a fire's stop sends no second SIGTERM, keeps th
const x = await standIn(t, p, s, { instance: "coder", run: "coder-run", ignoreTerm: true });
const chief = bind(s, "chief", "chief-run");
bind(s, "coder", "coder-run");
const fired = fire(s, chief, "coder");
const fired = await fire(s, chief, "coder");
const began = Date.now();
const pass = s.launcher.tick();
await until(() => x.terms() === 1);
@@ -848,12 +850,6 @@ test("row 194 T11: two stack roots with the same business id; a launch, a termin
// listing gets through to the host, which checks the file.
test("row 194 D3 item 5: the chief's session hires with its tool and launches its hire in the same session, then fires it", { skip, timeout: 90000 }, async (t) => {
const p = prepare(t);
// The shipped role file lists nothing hireable. The test's copy lists the CISO, which holds
// only message.send and record.write and no credential.
const roleFile = join(p.f.env.MOSAIC_ROLES_DIR, "chief-of-staff.json");
const role = JSON.parse(readFileSync(roleFile, "utf8"));
role.hireable = ["chief-information-security-officer"];
writeFileSync(roleFile, JSON.stringify(role, null, 2));
const s = await start(t, p);
const chief = (await s.carry(s.call("instance.launch", { instance: "chief" }).request)).done.run;
await s.claimed(chief);
@@ -861,15 +857,12 @@ test("row 194 D3 item 5: the chief's session hires with its tool and launches it
const sent = s.call("message.send", { to: "chief", class: "REQUEST", body: `TOOL ${name} ${JSON.stringify(args)}` }).id;
return (await until(() => s.call("replies", { id: sent })[0], 30000)).body;
};
const hired = JSON.parse(await ask("hire", { name: "sec-2", definition: "chief-information-security-officer", vars: { model: "claude-sonnet-5-5" }, reason: "a security lead for the review queue" }));
assert.ok(hired.transaction, JSON.stringify(hired));
const listed = s.call("instances").instances["sec-2"];
assert.equal(listed.launched, true);
// Hire vars set only model and limits.* (N3). harness and thinking are agent-layer route vars the
// human sets, so until then the launch refuses no-route.
assert.equal((await ask("launch", { instance: "sec-2" })).split("\n")[0], "refused: no-route");
const route = { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" };
s.call("instances.set", { revision: s.call("instances").revision, instance: "sec-2", op: "change", vars: route });
const role = readTemplate(p.f.env.MOSAIC_ROLES_DIR,"chief-information-security-officer");
const hired = JSON.parse(await ask("hire", {op:"hire",level:"org",name:"sec-2",role:{template:role.name,sha256:role.sha256},route:{harness:"pi",model:"claude-sonnet-5-5",thinking:"high"},reason:"a security lead for the review queue"}));
assert.ok(hired.id);
assert.equal(s.call("instances").instances["sec-2"],undefined,"raise writes nothing");
await s.runtime.broker.request(ownerConsole(s.runtime.broker,"acme"),{verb:"hire.resolve.console",args:{id:hired.id,choice:"yes",digest:hired.hire.digest}});
assert.equal(s.call("instances").instances["sec-2"].launched,true);
const launched = JSON.parse(await ask("launch", { instance: "sec-2" }));
assert.match(launched.run, /^r[0-9a-f]{12}$/);
assert.deepEqual(s.running("sec-2").map((x) => x.run), [launched.run]);
@@ -878,8 +871,9 @@ test("row 194 D3 item 5: the chief's session hires with its tool and launches it
assert.equal((await ask("launch", { instance: "NOT A NAME" })).split("\n")[0], "refused: instance-not-listed");
assert.equal((await ask("launch", { instance: "reviewer" })).split("\n")[0], "refused: instance-not-listed");
await s.claimed(launched.run);
const fired = JSON.parse(await ask("fire", { name: "sec-2", reason: "the review is done" }));
assert.ok(fired.transaction, JSON.stringify(fired));
const fired = JSON.parse(await ask("fire", {op:"fire",level:"org",name:"sec-2",reason:"the review is done"}));
assert.ok(fired.id);
await s.runtime.broker.request(ownerConsole(s.runtime.broker,"acme"),{verb:"hire.resolve.console",args:{id:fired.id,choice:"yes",digest:fired.hire.digest}});
await until(() => s.running("sec-2").length === 0, 30000);
// A fired instance stays in roles, off the listing, so the chief can't launch it again.
assert.equal(s.call("instances").instances["sec-2"].launched, false);
+8 -8
View File
@@ -358,7 +358,7 @@ test("the journal: a line with a wrong type refuses with exit 3 and names the fi
for (const [field, r] of bad) {
const text = `${JSON.stringify(dm)}\n${JSON.stringify(r)}\n`;
writeFileSync(file, text);
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal line 2 is malformed (${field}): ${file}`, `${field}: ${JSON.stringify(r)}`);
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal line 2 is malformed (${field})` && !e.message.includes(file), `${field}: ${JSON.stringify(r)}`);
assert.equal(readFileSync(file, "utf8"), text, "a refusal changes nothing");
}
const good = [dm, digest, { ...dm, outcome: "refused", messageId: null, status: 403 }, { ...dm, outcome: "gave-up", messageId: null }, { ...digest, outcome: "unknown", messageId: null }];
@@ -377,7 +377,7 @@ test("the journal: a symlinked directory refuses and says it is a link", (t) =>
const file = journalPath(root, "demo");
mkdirSync(dirname(dirname(file)), { mode: 0o700 });
symlinkSync(real, dirname(file));
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal directory must not be a symlink: ${dirname(file)}`);
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal directory must not be a symlink` && !e.message.includes(dirname(file)));
assert.deepEqual(readdirSync(real), [], "nothing was created through the link");
});
@@ -386,13 +386,13 @@ test("the journal: a dangling directory link, a parent that is a file and a jour
const dangling = journalPath(root, "demo");
mkdirSync(dirname(dirname(dangling)), { mode: 0o700 });
symlinkSync(join(root, "missing"), dirname(dangling));
assert.throws(() => openJournal(dangling), (e) => e.exitCode === 3 && e.message === `notify journal directory must not be a symlink: ${dirname(dangling)}`);
assert.throws(() => openJournal(dangling), (e) => e.exitCode === 3 && e.message === `notify journal directory must not be a symlink` && !e.message.includes(dirname(dangling)));
const under = join(root, "plain", "notify", "demo", "sent.jsonl");
writeFileSync(join(root, "plain"), "");
assert.throws(() => openJournal(under), (e) => e.exitCode === 3 && e.message === `notify journal directory cannot be created (ENOTDIR): ${dirname(under)}`);
assert.throws(() => openJournal(under), (e) => e.exitCode === 3 && e.message === `notify journal directory cannot be created (ENOTDIR)` && !e.message.includes(dirname(under)));
const asDir = journalPath(root, "acme");
mkdirSync(asDir, { recursive: true, mode: 0o700 });
assert.throws(() => openJournal(asDir), (e) => e.exitCode === 3 && e.message === `notify journal must be a regular file, mode 0600, owned by this user: ${asDir}`);
assert.throws(() => openJournal(asDir), (e) => e.exitCode === 3 && e.message === `notify journal must be a regular file, mode 0600, owned by this user` && !e.message.includes(asDir));
});
test("the journal: an append after the file was swapped for a symlink refuses and writes nothing through it", (t) => {
@@ -414,18 +414,18 @@ test("the journal: a directory it cannot write or create refuses with exit 3 and
// Modes are restored in finally: tmp()'s cleanup runs first among the after hooks.
chmodSync(dirname(file), 0o500);
try {
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal directory is not writable (EACCES): ${dirname(file)}`);
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal directory is not writable (EACCES)` && !e.message.includes(dirname(file)));
} finally {
chmodSync(dirname(file), 0o700);
}
openJournal(file);
chmodSync(file, 0o400);
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal is not writable (EACCES): ${file}`);
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal is not writable (EACCES)` && !e.message.includes(file));
const parent = join(root, "notify");
const other = journalPath(root, "acme");
chmodSync(parent, 0o500);
try {
assert.throws(() => openJournal(other), (e) => e.exitCode === 3 && e.message === `notify journal directory cannot be created (EACCES): ${dirname(other)}`);
assert.throws(() => openJournal(other), (e) => e.exitCode === 3 && e.message === `notify journal directory cannot be created (EACCES)` && !e.message.includes(dirname(other)));
} finally {
chmodSync(parent, 0o700);
}
+137
View File
@@ -0,0 +1,137 @@
// #1873 T1: org:<name> definitions at launch, and `mosaic org definitions`.
import test from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { createLauncher } from '../src/launcher.mjs';
import { main } from '../src/cli.mjs';
import { orgCommand } from '../src/org.mjs';
import { bootConfig, loadSystem } from '../src/config.mjs';
import { BusError } from '../../bus/src/broker.mjs';
import { startBroker } from '../../bus/src/runtime.mjs';
import { initializeControl } from '../../processing-control/src/state.mjs';
import { launchLogFile } from '../../seat/src/session.mjs';
import { entryFromTemplate, readTemplate, templateHash } from '../../organization/src/definitions.mjs';
import { REPO_ROLES, writeJson } from '../../organization/tests/helpers.mjs';
import { fixture, io, tmp, withoutHostCredentials } from './helpers.mjs';
const SCOUT = { rev: 1, role: { tools: ['read'], network: 'none', authority: { withinRole: [], crossRole: [] }, credentials: [] }, instructions: 'Read and report.\n' };
const stop = () => Object.assign(Error('fixture authorization stop'), { code: 'fixture-policy' });
const host = { business: 'acme', beforeClose: () => {}, op: async (x) => {
if (x.op === 'identity') return { business: 'acme', role: 'pm' };
if (x.op === 'mayLaunch') return { allow: true };
if (x.op === 'authorizeLaunch') throw stop();
return {};
} };
test('T1 an org: instance passes every launch check, and a missing definition refuses naming instance and definition', async (t) => {
const root = tmp(t);
// The org's own copy of the coder template, so the instance's credentials and tracker still match.
const builder = entryFromTemplate(readTemplate(REPO_ROLES, 'coder'));
const f = fixture(root, 'acme', (doc) => ({ ...doc, definitions: { builder }, roles: { ...doc.roles, coder: { ...doc.roles.coder, definition: 'org:builder' } } }));
const file = join(root, 'config/businesses/acme.json');
withoutHostCredentials(f.env, { piKey: true });
initializeControl(f.dataRoot);
const launcher = createLauncher({ host, system: JSON.parse(readFileSync(f.config, 'utf8')), env: f.env, bwrap: '/usr/bin/true', log: () => {} });
// Resolve, route, contract (the definition's instructions) and skills all pass; the fixture stops at authorization.
assert.equal((await launcher.fromSession('fixture-cap', 'coder')).error, 'fixture-policy');
// The chief names a definition the file doesn't declare: the whole file refuses, so a session's
// launch and the chief's launch both refuse.
writeJson(file, { ...f.doc, roles: { ...f.doc.roles, chief: { ...f.doc.roles.chief, definition: 'org:ghost' } } });
assert.deepEqual(await launcher.fromSession('fixture-cap', 'coder'), { ok: false, error: 'organization-refused' });
await assert.rejects(launcher.launchPm(), (e) => e.code === 'organization-refused' && /roles\.chief\.definition names org:ghost/.test(e.message));
const event = JSON.parse(readFileSync(launchLogFile(f.dataRoot, 'acme'), 'utf8').trim().split('\n').at(-1));
assert.equal(event.code, 'organization-refused');
assert.equal(event.instance, 'chief');
assert.match(event.why, /definition-missing: .*roles\.chief\.definition names org:ghost/);
await launcher.close?.();
// A host does not start on a file that names a missing definition.
assert.throws(() => createLauncher({ host, system: JSON.parse(readFileSync(f.config, 'utf8')), env: f.env, bwrap: '/usr/bin/true', log: () => {} }), /org:ghost/);
});
// The fixture bus holds SENTINEL as coder's gitea token, so the bus's secret check refuses it.
const SENTINEL = 'sk-test-NOT-A-SECRET-1234';
function files(dir) {
return readdirSync(dir, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? files(join(dir, e.name)) : e.isFile() ? [join(dir, e.name)] : []);
}
test('T1 mosaic org definitions: writes go through the bus and its secret check, each prints its receipt, and an agent shell refuses', async (t) => {
const root = tmp(t);
const builder = entryFromTemplate(readTemplate(REPO_ROLES, 'coder'));
const f = fixture(root, 'acme', (doc) => ({ ...doc, definitions: { builder }, roles: { ...doc.roles, coder: { ...doc.roles.coder, definition: 'org:builder' } } }));
const file = join(root, 'config/businesses/acme.json');
const changes = join(root, 'config/businesses/.history/acme/changes.jsonl');
writeFileSync(f.doc.roles.coder.credentials.gitea.file, SENTINEL);
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: 'acme', env: f.env });
const b = await startBroker({ dataRoot: f.dataRoot, businesses: boot.businesses, organization: boot.organization });
t.after(() => b.close());
const human = b.broker.bindHuman({ business: 'acme', human: f.doc.human, via: 'cli', outsideAgent: true });
const calls = [];
// The human transport's shape: a bus refusal arrives as its code.
const deps = { system: { dataRoot: f.dataRoot }, transport: () => async (verb, args = {}) => {
calls.push(verb);
try { return structuredClone(b.broker.request(human, { verb, args })); }
catch (e) { if (!(e instanceof BusError)) throw e; throw Object.assign(new Error(e.code), { code: e.code }); }
} };
const run = async (args, env = f.env) => { const x = io(env); await main(['org', 'definitions', ...args], x, deps); return JSON.parse(x.out.text); };
const hash = templateHash(f.roles, 'coder');
assert.ok((await run(['templates'])).some((h) => h.template === 'coder' && h.sha256 === hash));
const imported = await run(['acme', 'import', 'coder', '--as', 'copy']);
assert.deepEqual({ ...imported, revision: 0, transaction: 0 }, { verb: 'import', organization: 'acme', definition: 'copy', rev: 1, revision: 0, transaction: 0, from: { template: 'coder', sha256: hash } });
const spec = join(root, 'scout.json');
writeFileSync(spec, JSON.stringify({ role: SCOUT.role, instructions: SCOUT.instructions }));
assert.equal((await run(['acme', 'invent', 'scout', '--from', spec])).rev, 1);
writeFileSync(spec, JSON.stringify({ role: SCOUT.role, instructions: 'Read, then report.\n' }));
const edited = await run(['acme', 'edit', 'scout', '--from', spec, '--if-rev', '1']);
assert.equal(edited.verb, 'edit');
assert.equal(edited.rev, 2);
assert.deepEqual((await run(['acme', 'list'])).definitions.map((d) => [d.name, d.rev]), [['builder', 1], ['copy', 1], ['scout', 2]]);
assert.equal((await run(['acme', 'show', 'scout'])).instructions, 'Read, then report.\n');
assert.equal((await run(['acme', 'diff', 'copy'])).changed, false);
assert.equal((await run(['acme', 'remove', 'scout', '--if-rev', '2'])).removedRev, 2);
assert.deepEqual(calls, ['definition.import', 'definition.invent', 'definition.edit', 'definition.remove']);
const committed = readFileSync(changes, 'utf8').trim().split('\n').map((l) => JSON.parse(l)).filter((e) => e.state === 'committed').slice(-4);
assert.deepEqual(committed.map((e) => e.actor), Array(4).fill(f.doc.human));
// A definition holding a token the bus holds refuses on invent and on edit, in valid JSON, and
// the token reaches no message, no organization file and nothing the bus wrote.
const before = readFileSync(file);
const beforeChanges = readFileSync(changes);
writeFileSync(spec, JSON.stringify({ role: SCOUT.role, instructions: `Use ${SENTINEL} to report.\n` }));
for (const args of [['acme', 'invent', 'leak', '--from', spec], ['acme', 'edit', 'copy', '--from', spec, '--if-rev', '1']]) {
await assert.rejects(run(args), (e) => e.code === 'credential-leak' && e.exitCode === 2 && !e.message.includes(SENTINEL) && !String(e.stack).includes(SENTINEL));
}
assert.deepEqual(readFileSync(file), before);
assert.deepEqual(readFileSync(changes), beforeChanges);
for (const path of files(f.dataRoot)) assert.ok(!readFileSync(path).includes(SENTINEL), path);
// An unparsable file never echoes; a key the file's checks refuse is named before the bus is
// called; a stale --if-rev and an in-use definition refuse with what they mean.
writeFileSync(spec, `{"role": ${SENTINEL}`);
await assert.rejects(run(['acme', 'invent', 'other', '--from', spec]), (e) => e.exitCode === 2 && !e.message.includes('sk-test'));
writeFileSync(spec, JSON.stringify({ role: { ...SCOUT.role, network: 'everywhere' }, instructions: 'x\n' }));
const asked = calls.length;
await assert.rejects(run(['acme', 'invent', 'other', '--from', spec]), /definitions\.other/);
assert.equal(calls.length, asked);
await assert.rejects(run(['acme', 'remove', 'copy', '--if-rev', '7']), (e) => /^definition-rev-conflict: definition copy is not at rev 7/.test(e.message));
await assert.rejects(run(['acme', 'remove', 'builder', '--if-rev', '1']), (e) => /^definition-in-use: definition builder is named by coder/.test(e.message));
assert.deepEqual(readFileSync(file), before);
// Inside an agent run every write refuses before it reads anything or calls the bus; reads still work.
const agent = { ...f.env, MOSAIC_RUN_ID: 'run-1' };
const sent = calls.length;
for (const args of [['acme', 'import', 'reviewer'], ['acme', 'invent', 'x', '--from', spec], ['acme', 'edit', 'copy', '--from', spec, '--if-rev', '1'], ['acme', 'remove', 'copy', '--if-rev', '1']]) {
await assert.rejects(run(args, agent), (e) => e.exitCode === 3 && /human shell/.test(e.message));
}
assert.equal(calls.length, sent);
assert.deepEqual(readFileSync(file), before);
assert.equal((await run(['acme', 'list'], agent)).definitions.length, 2);
// With no bus call to make, a write refuses rather than writing without the secret check.
const x = io(f.env);
await assert.rejects(orgCommand(['definitions', 'acme', 'remove', 'copy', '--if-rev', '1'], x), (e) => e.exitCode === 3 && /organization bus/.test(e.message));
assert.deepEqual(readFileSync(file), before);
});
+5 -3
View File
@@ -40,8 +40,10 @@ test('console stop quarantines dead and wrong-process states without signaling',
});
test('live console from another entry path remains recorded and is not signalled', { timeout: 30000 }, async t => {
const f = await fixture(), dir = join(f.ctx.dataRoot, 'console'), alias = join(f.root, 'console.mjs');
mkdirSync(dir, { mode: 0o700 });
// The other entry sits under the sentinel directory: the refusal must name neither (PR #1916).
const SENTINEL = 'sk-test-NOT-A-SECRET-1234';
const f = await fixture(), dir = join(f.ctx.dataRoot, 'console'), alias = join(f.root, SENTINEL, 'console.mjs');
mkdirSync(dir, { mode: 0o700 }); mkdirSync(join(f.root, SENTINEL), { mode: 0o700 });
symlinkSync(new URL('../../webui/src/cli.mjs', import.meta.url).pathname, alias);
const child = spawn(process.execPath, [alias, 'serve', '--port', '0'], { env: humanEnv({ HOME: f.root, MOSAIC_CONFIG: join(f.ctx.configDir, 'config.json') }), stdio: ['ignore', 'pipe', 'pipe'] });
t.after(async () => { if (child.exitCode === null && child.signalCode === null) { const ended = once(child, 'exit'); child.kill('SIGTERM'); await ended; } });
@@ -53,7 +55,7 @@ test('live console from another entry path remains recorded and is not signalled
const record = { pid: child.pid, startTime: startTimeOf(child.pid), url };
json(join(dir, 'console.json'), record);
await assert.rejects(consoleStart(f.ctx), /console state exists/);
await assert.rejects(consoleStop(f.ctx), e => /another path/.test(e.message) && e.message.includes(alias));
await assert.rejects(consoleStop(f.ctx), e => { assert.equal(e.exitCode, 3); assert.equal(e.message, 'console was started from another checkout; stop it there'); assert.ok(!e.message.includes(SENTINEL) && !e.message.includes(alias), e.message); return true; });
assert.deepEqual(JSON.parse(readFileSync(join(dir, 'console.json'), 'utf8')), record);
assert.equal(startTimeOf(child.pid), record.startTime);
assert.equal(readdirSync(dir).filter(x => x.startsWith('console.json.stale-')).length, 0);
+4 -2
View File
@@ -38,10 +38,12 @@ const IMPORT=new RegExp([
`${DYNAMIC.source}\\s*['"](\\.{1,2}\\/[^'"]+)['"]\\s*\\)`,
].join('|'),'g');
const DYNAMIC_LITERAL=/^import\s*\(\s*(['"])[^'"\n]*\1\s*\)/;
// The plug-in loader's one computed import, allowed only in that file and only in this exact form. It loads
// The plug-in loader's computed imports, allowed only in that file and these exact forms. Core loads
// packages/plugins/<kind>/<name>/index.mjs after checking that path, and a real copy ships all of
// packages/plugins, so a fixture that never loads a plug-in needs nothing more from it.
const LOADER_FILE='packages/plugins/src/loader.mjs',LOADER_IMPORT=/^import\(pathToFileURL\(expected\)\.href\)/;
// PL1 adds the checked instance package entry with its verified hash as a cache key. Installed
// bytes belong to the instance, not to a release fixture's import closure.
const LOADER_FILE='packages/plugins/src/loader.mjs',LOADER_IMPORT=/^import\(pathToFileURL\(expected\)\.href\)|^import\(pathToFileURL\(entry\)\.href \+ '\?pin=' \+ checked\.manifest\.sha256\)/;
const REGEX_AFTER='(,=:[!&|?{};+-*%<>~^';
const inert=(literals,index)=>literals.some(([from,to])=>index>=from&&index<to);
const sources=(dir)=>readdirSync(dir,{withFileTypes:true}).flatMap((e)=>e.isDirectory()?sources(join(dir,e.name)):e.name.endsWith('.mjs')?[join(dir,e.name)]:[]);
@@ -43,6 +43,14 @@ test('the loader exception holds only for the loader file and its exact computed
assert.throws(()=>organizationImportedFiles(tree({[`${ORG}a.mjs`]:"import '../../plugins/src/loader.mjs';\n",'repo/packages/plugins/src/loader.mjs':"await import(x);\n"})),/not a plain string literal/);
});
test('PL1 pinned import exception holds only for the checked loader entry and exact hash cache key',()=>{
const computed="await import(pathToFileURL(entry).href + '?pin=' + checked.manifest.sha256);\n";
const at=(file,text=computed)=>tree({[`${ORG}a.mjs`]:`import '../../plugins/src/${file}';\n`,[`repo/packages/plugins/src/${file}`]:text});
assert.deepEqual(organizationImportedFiles(at('loader.mjs')),['packages/plugins/src/loader.mjs']);
assert.throws(()=>organizationImportedFiles(at('other.mjs')),/not a plain string literal/);
for(const changed of [computed.replace('entry','arbitrary'),computed.replace('?pin=','?unchecked='),computed.replace('checked.manifest.sha256','input')])assert.throws(()=>organizationImportedFiles(at('loader.mjs',changed)),/not a plain string literal/);
});
test('every static form and a literal dynamic import is found, and the imports of what it finds in turn', ()=>{
const repo=withOrg([
"import x from '../../other/src/from.mjs';",
+312
View File
@@ -0,0 +1,312 @@
// PR #1916 redaction (org-redact5): a CLI refusal names the file by its label,
// never by host path. Every fixture here sits under a directory named for the
// sentinel, so a refusal that quoted the path would carry it.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { createServer } from "node:net";
import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { gitSocketPath } from "../../seat/src/session.mjs";
import { withLock } from "../src/access.mjs";
import { fixtureFile, readFixture } from "../src/dev-fixture.mjs";
import { createGitSocket } from "../src/git-socket.mjs";
import { consoleStart, consoleStop } from "../src/org-console.mjs";
import { claimHostLock, hostLock } from "../src/host.mjs";
import { journalPath, openJournal } from "../src/notifier.mjs";
import { readNotifyConfig } from "../src/notify-config.mjs";
import { orgCommand } from "../src/org.mjs";
import { instancesCommand } from "../src/instances.mjs";
import { FIXTURE_ENTRY } from "../../plugins/model-provider/fixture/index.mjs";
const SENTINEL = "sk-test-NOT-A-SECRET-1234";
const ROOT_SKIP = process.getuid() === 0 && "root ignores the modes";
function under(t) {
const top = mkdtempSync(join(tmpdir(), "redact-"));
t.after(() => rmSync(top, { recursive: true, force: true }));
const root = join(top, SENTINEL);
mkdirSync(root, { mode: 0o700 });
return root;
}
// The refusal is exactly `message` (a string) or matches it (a RegExp), with exit
// code `exitCode`, and carries neither the sentinel nor any of `paths`.
function refusal(message, { exitCode = 3, paths = [], code } = {}) {
return (error) => {
assert.equal(error.name, "CliError");
assert.equal(error.exitCode, exitCode);
if (code !== undefined) assert.equal(error.code, code);
if (message instanceof RegExp) assert.match(error.message, message);
else assert.equal(error.message, message);
assert.ok(!error.message.includes(SENTINEL), error.message);
for (const path of paths) assert.ok(!error.message.includes(path), error.message);
return true;
};
}
// --- access.json lock ---
test("access lock: an unreadable lock is named by label, never by host path", (t) => {
const file = join(under(t), "access.json");
writeFileSync(`${file}.lock`, "not-a-pid\n", { mode: 0o600 });
assert.throws(() => withLock(file, () => assert.fail("must refuse")),
refusal("refused: access file lock is unreadable or not a pid; if no write is running, remove it by hand and try again", { paths: [file] }));
});
test("access lock: a lock left by a dead pid is named by label, never by host path", (t) => {
const file = join(under(t), "access.json");
const dead = spawnSync(process.execPath, ["-e", ""]);
writeFileSync(`${file}.lock`, `${dead.pid}\n`, { mode: 0o600 });
assert.throws(() => withLock(file, () => assert.fail("must refuse")),
refusal(`refused: access file lock is left by pid ${dead.pid}, which is not running; if no write is running, remove it by hand and try again`, { paths: [file] }));
});
// --- bus host lock ---
const MINE = { pid: process.pid, startTime: "1", business: "acme", startedAt: "2026-11-10T00:00:00.000Z" };
const STALE = { pid: 999999999, startTime: "1", business: "acme", startedAt: "2026-11-09T00:00:00.000Z" };
function hostRoot(t, lock) {
const dataRoot = join(under(t), "data");
mkdirSync(join(dirname(hostLock(dataRoot))), { recursive: true, mode: 0o700 });
if (lock !== undefined) writeFileSync(hostLock(dataRoot), lock, { mode: 0o600 });
return dataRoot;
}
test("host lock: an unreadable host.lock refuses by label, never by host path", (t) => {
const dataRoot = hostRoot(t, "{not json");
assert.throws(() => claimHostLock(dataRoot, MINE), refusal("bus host state is unreadable", { paths: [dataRoot] }));
});
test("host lock: a malformed host.lock refuses by label, never by host path", (t) => {
const dataRoot = hostRoot(t, JSON.stringify({ pid: "x" }));
assert.throws(() => claimHostLock(dataRoot, MINE), refusal("bus host state is malformed", { paths: [dataRoot] }));
});
test("host lock: a stale host.lock refused on request is named by label, never by host path", (t) => {
const dataRoot = hostRoot(t, JSON.stringify(STALE));
assert.throws(() => claimHostLock(dataRoot, MINE, { refuseStale: true }), refusal("stale bus host lock", { paths: [dataRoot] }));
});
test("host lock: a lock that keeps changing is named by label, never by host path", (t) => {
const dataRoot = hostRoot(t, JSON.stringify(STALE));
let n = 0;
const race = () => writeFileSync(hostLock(dataRoot), JSON.stringify({ ...STALE, startedAt: `2026-11-09T00:00:${String(++n % 60).padStart(2, "0")}.000Z` }), { mode: 0o600 });
assert.throws(() => claimHostLock(dataRoot, MINE, { between: race }), refusal("bus host lock kept changing; no bus host started", { paths: [dataRoot] }));
});
// --- notify journal ---
test("notify journal: a directory link, dangling or not, is refused by label", (t) => {
const root = under(t);
const dangling = journalPath(root, "demo");
mkdirSync(dirname(dirname(dangling)), { recursive: true, mode: 0o700 });
symlinkSync(join(root, "missing"), dirname(dangling));
assert.throws(() => openJournal(dangling), refusal("notify journal directory must not be a symlink", { paths: [dangling] }));
const real = join(root, "real");
mkdirSync(real, { mode: 0o700 });
const linked = journalPath(root, "acme");
symlinkSync(real, dirname(linked));
assert.throws(() => openJournal(linked), refusal("notify journal directory must not be a symlink", { paths: [linked] }));
});
test("notify journal: a parent that is a file is refused by its error code", (t) => {
const root = under(t);
writeFileSync(join(root, "plain"), "");
const file = join(root, "plain", "notify", "demo", "sent.jsonl");
assert.throws(() => openJournal(file), refusal("notify journal directory cannot be created (ENOTDIR)", { paths: [file] }));
});
test("notify journal: a directory of the wrong mode is refused by label", (t) => {
const file = journalPath(under(t), "demo");
mkdirSync(dirname(file), { recursive: true, mode: 0o755 });
chmodSync(dirname(file), 0o755);
assert.throws(() => openJournal(file), refusal("notify journal directory must be mode 0700 and owned by this user", { paths: [file] }));
});
test("notify journal: a directory it cannot write or create is refused by its error code", { skip: ROOT_SKIP }, (t) => {
const root = under(t);
const file = journalPath(root, "demo");
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
chmodSync(dirname(file), 0o500);
try {
assert.throws(() => openJournal(file), refusal("notify journal directory is not writable (EACCES)", { paths: [file] }));
} finally {
chmodSync(dirname(file), 0o700);
}
const parent = dirname(dirname(file));
const other = journalPath(root, "acme");
chmodSync(parent, 0o500);
try {
assert.throws(() => openJournal(other), refusal("notify journal directory cannot be created (EACCES)", { paths: [other] }));
} finally {
chmodSync(parent, 0o700);
}
});
test("notify journal: a journal link, an unwritable journal and a directory journal are refused by label", { skip: ROOT_SKIP }, (t) => {
const root = under(t);
const file = journalPath(root, "demo");
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
writeFileSync(join(root, "elsewhere.jsonl"), "", { mode: 0o600 });
symlinkSync(join(root, "elsewhere.jsonl"), file);
assert.throws(() => openJournal(file), refusal("notify journal must not be a symlink", { paths: [file] }));
rmSync(file);
writeFileSync(file, "", { mode: 0o400 });
chmodSync(file, 0o400);
assert.throws(() => openJournal(file), refusal("notify journal is not writable (EACCES)", { paths: [file] }));
const asDir = journalPath(root, "acme");
mkdirSync(asDir, { recursive: true, mode: 0o700 });
assert.throws(() => openJournal(asDir), refusal("notify journal must be a regular file, mode 0600, owned by this user", { paths: [asDir] }));
});
test("notify journal: a journal of the wrong mode and a malformed line are refused by label", (t) => {
const file = journalPath(under(t), "demo");
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
writeFileSync(file, "", { mode: 0o644 });
chmodSync(file, 0o644);
assert.throws(() => openJournal(file), refusal("notify journal must be a regular file, mode 0600, owned by this user", { paths: [file] }));
chmodSync(file, 0o600);
writeFileSync(file, "not json\n");
assert.throws(() => openJournal(file), refusal("notify journal line 1 is malformed (json)", { paths: [file] }));
});
// --- notifier config ---
function notifyRoot(t, text, mode = 0o600) {
const dataRoot = join(under(t), "data");
const dir = join(dataRoot, "notify", "acme");
mkdirSync(dir, { recursive: true, mode: 0o700 });
if (text !== undefined) {
writeFileSync(join(dir, "notify.json"), text, { mode });
chmodSync(join(dir, "notify.json"), mode);
}
return dataRoot;
}
test("notifier config: a missing file names the business and its place under the data root", (t) => {
const dataRoot = notifyRoot(t);
assert.throws(() => readNotifyConfig(dataRoot, "acme"),
refusal('no notifier config for acme: write {"notifyVersion": 1, "binding": "<discord binding>"} to notify/acme/notify.json under the data root, mode 0600, or "binding": null to run without DMs', { paths: [dataRoot] }));
});
test("notifier config: a file of the wrong mode names the business only", (t) => {
const dataRoot = notifyRoot(t, JSON.stringify({ notifyVersion: 1, binding: null }), 0o644);
assert.throws(() => readNotifyConfig(dataRoot, "acme"), refusal("notifier config for acme must be a regular file, mode 0600, owned by this user", { paths: [dataRoot] }));
});
test("notifier config: a file that is not JSON names the business only", (t) => {
const dataRoot = notifyRoot(t, "{not json");
assert.throws(() => readNotifyConfig(dataRoot, "acme"), refusal("notifier config for acme is not JSON", { paths: [dataRoot] }));
});
test("notifier config: a file of the wrong shape names the business only", (t) => {
const dataRoot = notifyRoot(t, JSON.stringify({ notifyVersion: 1, binding: null, extra: 1 }));
assert.throws(() => readNotifyConfig(dataRoot, "acme"), refusal('notifier config for acme must be exactly {"notifyVersion": 1, "binding": <binding name> | null}', { paths: [dataRoot] }));
});
// --- dev fixture ---
const RECORD = { fixtureVersion: 1, model: "deterministic", endpoint: "http://127.0.0.1:45678", port: 45678, pid: 999999, instance: "fxtest", entry: FIXTURE_ENTRY };
function fixtureRoot(t, { record = RECORD, text = JSON.stringify(record), dirMode = 0o700, fileMode = 0o600 } = {}) {
const dataRoot = join(under(t), "data");
const file = fixtureFile(dataRoot);
mkdirSync(dirname(file), { recursive: true, mode: dirMode });
chmodSync(dirname(file), dirMode);
if (text !== null) {
writeFileSync(file, text, { mode: fileMode });
chmodSync(file, fileMode);
}
return dataRoot;
}
test("dev fixture: a shared fixture directory is refused by label", (t) => {
const dataRoot = fixtureRoot(t, { dirMode: 0o755 });
assert.throws(() => readFixture(dataRoot), refusal("fixture-state-unsafe: dev-fixture directory is not a private directory of this user", { code: "fixture-state-unsafe", paths: [dataRoot] }));
});
test("dev fixture: a linked fixture file is refused by label and error code", (t) => {
const dataRoot = fixtureRoot(t, { text: null });
writeFileSync(join(dataRoot, "elsewhere.json"), JSON.stringify(RECORD), { mode: 0o600 });
symlinkSync(join(dataRoot, "elsewhere.json"), fixtureFile(dataRoot));
assert.throws(() => readFixture(dataRoot), refusal("fixture-state-unsafe: fixture.json can't be opened (ELOOP)", { code: "fixture-state-unsafe", paths: [dataRoot] }));
});
test("dev fixture: a shared fixture file is refused by label", (t) => {
const dataRoot = fixtureRoot(t, { fileMode: 0o644 });
assert.throws(() => readFixture(dataRoot), refusal("fixture-state-unsafe: fixture.json is not a private file of this user", { code: "fixture-state-unsafe", paths: [dataRoot] }));
});
test("dev fixture: a fixture file that is not JSON is refused by label", (t) => {
const dataRoot = fixtureRoot(t, { text: "not json" });
assert.throws(() => readFixture(dataRoot), refusal("fixture-state-invalid: fixture.json is not JSON", { code: "fixture-state-invalid", paths: [dataRoot] }));
});
test("dev fixture: a non-loopback endpoint and a record of the wrong shape are refused by label", (t) => {
let dataRoot = fixtureRoot(t, { record: { ...RECORD, endpoint: "http://0.0.0.0:45678" } });
assert.throws(() => readFixture(dataRoot), refusal("fixture-endpoint-not-loopback: fixture.json names an endpoint that is not http://127.0.0.1:<port>", { code: "fixture-endpoint-not-loopback", paths: [dataRoot] }));
dataRoot = fixtureRoot(t, { record: { ...RECORD, port: 1 } });
assert.throws(() => readFixture(dataRoot), refusal("fixture-state-invalid: fixture.json is not a fixture record", { code: "fixture-state-invalid", paths: [dataRoot] }));
});
// --- git socket ---
test("git socket: a path that exists and is not a socket is refused by label", async (t) => {
const dataRoot = join(under(t), "data");
const path = gitSocketPath(dataRoot);
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
writeFileSync(path, "", { mode: 0o600 });
const sock = createGitSocket({ host: {}, business: { id: "acme", projects: {} }, dataRoot, env: {}, log: () => {} });
await assert.rejects(sock.listen(), refusal("git socket path exists and isn't a socket", { paths: [dataRoot, path] }));
});
// --- console launch ---
// The Console is given a port this test holds, so its listen fails and it exits at
// startup. Should it start anyway, it is stopped before the tree goes.
test("console: a Console that exits at startup is named by its log under the data root, never by host path", { timeout: 60000 }, async (t) => {
const root = under(t);
const ctx = { configDir: join(root, "config"), dataRoot: join(root, "data"), instance: "redact", env: { PATH: process.env.PATH, HOME: root } };
for (const dir of [ctx.configDir, ctx.dataRoot]) mkdirSync(dir, { mode: 0o700 });
const held = createServer();
await new Promise((resolve, reject) => held.once("error", reject).listen(0, "127.0.0.1", resolve));
try {
await assert.rejects(consoleStart(ctx, { detach: true, port: String(held.address().port), warn: () => {} }),
refusal(/^console failed to start; see console\/console-[0-9]+-[0-9]+\.log under the data root$/, { paths: [ctx.dataRoot] }));
} finally {
await consoleStop(ctx, { warn: () => {} }).catch(() => {});
await new Promise((resolve) => held.close(resolve));
}
});
// --- review round (org-redact5 Sol P2 #3): documents, --vars and raw fs errors ---
const quietIo = () => ({ stdout: { write: () => true }, stderr: { write: () => true } });
test("org validate: a missing or invalid document is refused by kind, never by host path or bytes", async (t) => {
const root = under(t), missing = join(root, "missing.json"), bad = join(root, "bad.json");
writeFileSync(bad, `{"${SENTINEL}": `, { mode: 0o600 });
await assert.rejects(orgCommand(["validate", missing], quietIo()), refusal("cannot read organization document: ENOENT", { exitCode: 2, paths: [missing, root] }));
await assert.rejects(orgCommand(["validate", bad], quietIo()), refusal("cannot read organization document: not valid JSON", { exitCode: 2, paths: [bad, root] }));
});
test("org template render: a raw filesystem error is named by operation and code, never by host path", async (t) => {
const root = under(t), output = join(root, "alpha.json");
writeFileSync(output, "{}\n", { mode: 0o600 });
await assert.rejects(orgCommand(["template", "render", "alpha", "--output", output], quietIo()),
refusal("EEXIST: cannot open (EEXIST)", { exitCode: 2, paths: [output, root] }));
});
test("instance set: a missing or invalid --vars file is refused by flag and kind, never by host path or bytes", async (t) => {
const root = under(t), missing = join(root, "vars.json"), bad = join(root, "bad.json");
writeFileSync(bad, `{"${SENTINEL}": `, { mode: 0o600 });
const rev = "a".repeat(64);
const callFor = () => assert.fail("must refuse before any call");
await assert.rejects(instancesCommand(["instance", "set", "acme", "coder", "--vars", missing, "--revision", rev], quietIo(), callFor),
refusal("--vars file: ENOENT", { exitCode: 4, paths: [missing, root] }));
await assert.rejects(instancesCommand(["instance", "set", "acme", "coder", "--vars", bad, "--revision", rev], quietIo(), callFor),
refusal("--vars file: not valid JSON", { exitCode: 4, paths: [bad, root] }));
});
@@ -169,7 +169,7 @@ test("rename r2 B2: a draft file named like an installed ID: org validate reads
rmSync(join(cwd, "acme"));
const noDraft = spawn(MOSAIC, ["org", "validate", "acme"], env, cwd);
assert.equal(noDraft.status, 2);
assert.match(noDraft.stderr, /^mosaic: cannot read document acme: /);
assert.equal(noDraft.stderr, "mosaic: cannot read organization document: ENOENT\n");
writeJson(join(cwd, "acme"), await templateRender("acme", { fixture: "local" }));
rmSync(join(root, "config", "businesses", "acme.json"));
const noInstalled = spawn(MOSAIC, ["org", "config", "validate", "acme"], env, cwd);
+23 -2
View File
@@ -21,11 +21,11 @@ const OTHER = '11111111-2222-4333-8444-555555555555';
let n = 0;
// An instance with org alpha and one finished run for THREAD.
async function world({ run = 'r0123456789ab' } = {}) {
async function world({ run = 'r0123456789ab', under = HOME } = {}) {
const name = `ev${++n}`;
const env = { PATH: process.env.PATH, HOME, MOSAIC_CONFIG: join(HOME, '.config', `mosaic-${name}`, 'config.json') };
mkdirSync(dirname(env.MOSAIC_CONFIG), { recursive: true, mode: 0o700 });
const dataRoot = join(HOME, `data-${name}`); mkdirSync(dataRoot, { mode: 0o700 });
const dataRoot = join(under, `data-${name}`); mkdirSync(dataRoot, { recursive: true, mode: 0o700 });
const org = await reserveOrg({ configDir: dirname(env.MOSAIC_CONFIG), dataRoot, instance: name, principal: DEV_OWNER }, 'alpha');
const runDir = join(runsDir(org.root, 'alpha'), run);
mkdirSync(join(runDir, 'bundle'), { recursive: true, mode: 0o700 });
@@ -132,6 +132,27 @@ test('no launch for the thread, an unknown org and a usage error', async () => {
await assert.rejects(runCommand(['evidence', 'alpha', '--thread', THREAD], io({ PATH: process.env.PATH, HOME }), { system: { dataRoot: w.dataRoot } }), e => e.exitCode === 3 && e.code === 'run-evidence-instance-refused');
});
// PR #1916 (org-redact5): a refusal names the launch log by label, never by host path.
const SENTINEL = 'sk-test-NOT-A-SECRET-1234';
const noPath = (e, w) => !e.message.includes(SENTINEL) && !e.message.includes(w.dataRoot) && !e.message.includes(w.log);
test('a launch log of the wrong mode refuses by label, never by host path', async () => {
const w = await world({ under: join(HOME, SENTINEL) });
chmodSync(w.log, 0o644);
const r = await evidence(w, ['--thread', THREAD, '--json']);
assert.equal(r.error?.exitCode, 3);
assert.equal(r.error.message, 'run-evidence-refused: launch log: mode 644, expected 600');
assert.ok(noPath(r.error, w), r.error.message);
});
test('no launch for the thread names the launch log by label, never by host path', async () => {
const w = await world({ under: join(HOME, SENTINEL) });
const r = await evidence(w, ['--thread', '99999999-2222-4333-8444-555555555555', '--json']);
assert.equal(r.error?.exitCode, 2);
assert.equal(r.error.message, 'no launch for thread 99999999-2222-4333-8444-555555555555 in the launch log');
assert.ok(noPath(r.error, w), r.error.message);
});
test('a thread name resolves through the bus with the org as business', async () => {
const w = await world();
const seen = [];
+6 -2
View File
@@ -130,9 +130,13 @@ export function writeTurn(dir, id, record) {
return path;
}
// A parse refusal names the turn by its id, never by host path (PR #1916); a
// listed file's id is named only when it passes TURN_ID.
const turnLabel = (id) => (TURN_ID.test(id) ? `turn record ${id}` : "turn record");
export function readTurn(dir, id) {
const path = turnPath(dir, id);
return parseJsonFileText(readFileSync(path, "utf8"), path, "turn record");
return parseJsonFileText(readFileSync(path, "utf8"), path, turnLabel(String(id)));
}
export function listTurns(dir) {
@@ -140,7 +144,7 @@ export function listTurns(dir) {
if (!existsSync(turns)) return [];
return readdirSync(turns)
.filter((f) => f.endsWith(".json"))
.map((f) => parseJsonFileText(readFileSync(join(turns, f), "utf8"), join(turns, f), "turn record"));
.map((f) => parseJsonFileText(readFileSync(join(turns, f), "utf8"), join(turns, f), turnLabel(f.slice(0, -".json".length))));
}
// The daily ceiling counts admissions on the current UTC date. An admission
+3 -2
View File
@@ -28,9 +28,10 @@ for (const [field, edit] of [
});
test('gateway url refusal omits its value', () => assert.throws(() => createGateway({ url: secret, token: 'fake' }), safe('url', DiscordError, { exitCode: 1 })));
for (const mode of ['read', 'list']) test(`turn ${mode} parser omits file bytes and keeps SyntaxError`, t => {
const root = scratch(t); mkdirSync(join(root, 'turns'));
const root = join(scratch(t), secret); mkdirSync(join(root, 'turns'), { recursive: true });
const file = join(root, 'turns', 'turn-1.json'); writeFileSync(file, secret);
assert.throws(() => mode === 'read' ? readTurn(root, 'turn-1') : listTurns(root), safe(file, SyntaxError));
// The refusal names the turn by its id, never by host path (PR #1916).
assert.throws(() => mode === 'read' ? readTurn(root, 'turn-1') : listTurns(root), safe('turn record turn-1 is not valid JSON', SyntaxError, {}, file));
});
for (const duplicate of [true, false]) test(`binding approver ${duplicate ? 'duplicate' : 'invalid'} name omits user values`, t => {
+1 -1
View File
@@ -41,7 +41,7 @@ export function webPlan(resolved, harness, builtinWeb) {
}
return { source, builtin: granted && source === "harness-builtin" };
}
export const builtinTools = (tools) => tools.filter((t) => t !== "web");
export const builtinTools = (tools) => tools.filter((t) => t !== "web" && !t.includes("."));
export const SOURCES = Object.freeze({
piExtension: fileURLToPath(new URL("./pi-extension.mjs", import.meta.url)),
claudeGate: fileURLToPath(new URL("./claude-gate.mjs", import.meta.url)),
+2 -1
View File
@@ -363,11 +363,12 @@ export function toolServer({ path, tools, client, local }) {
if (!tool) return answer({ ok: false, error: "unknown-tool" });
const args = request.args && typeof request.args === "object" && !Array.isArray(request.args) ? request.args : {};
try {
const result = Object.hasOwn(local, tool.verb) ? await local[tool.verb](args, { signal: controller.signal }) : await client.call(tool.verb, args);
const result = Object.hasOwn(local, tool.verb) ? await local[tool.verb](args, { signal: controller.signal }) : await client.call(tool.verb, tool.pluginTool ? {tool:tool.pluginTool,arguments:args} : args);
answer({ ok: true, result });
} catch (e) {
answer({ ok: false, error: /^[a-z-]{1,64}$/.test(e.code ?? "") ? e.code : "tool-failed",
...refusalGuidance(e.code),
...(e.code === "plugin-disabled" && /^[a-z][a-z0-9-]{0,31}$/.test(e.reason ?? "") ? {plugin:e.reason} : {}),
...(typeof e.reason === "string" && request.tool === "advance_request" && tool.verb === "request.advance" && e.code === "request-evidence-required"
? { reason: e.reason.slice(0, 300) } : tool.verb === "record.write" && e.code === "project-record-invalid" && typeof e.reason === "string"
? { reason: e.reason.slice(0, 300) } : {}) });
+16 -13
View File
@@ -96,7 +96,9 @@ const MESSAGE_CLASSES = ["REQUEST", "ASSIGNMENT", "REVIEW-REQUEST", "REVIEW-RESU
// "working.land" go to the host's git socket.
export function typedTools({ resolved, arbiter = false, tracker = false, projects = [], webTyped = true, land = false, library = false }) {
const held = new Set([...resolved.limits.authority.withinRole, ...resolved.limits.authority.crossRole]);
const tools = [];
const tools = (resolved.pluginTools ?? []).filter(t=>resolved.limits.tools.includes(t.name)).map(t=>({
name:t.name, verb:"plugin.tool", pluginTool:t.name, action:null, description:t.description, parameters:t.arguments,
}));
const add = (name, verb, action, description, parameters) => tools.push({ name, verb, action, description, parameters });
if (held.has("message.send")) {
add("send_message", "message.send", "message.send", "Send a message to another role instance, or to \"human\". Your final answer to a message is sent back to its sender for you; use this for anything else. A reply to an answer gets no automatic answer; use class REQUEST to ask a follow-up.", obj({
@@ -221,21 +223,22 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project
reason: str("Why the run ends, 1 to 500 characters", { minLength: 1, maxLength: 500 }),
}, ["role", "reason"]));
}
// Row 194 (#1692, PRD 1.7 REQ-COS-3): the chief of staff's hire and fire, routed within-role. The
// broker checks the definition against the role files and the veto on every call.
// A4: these propose changes. Only a person with org.hire can approve them.
if (held.has("role.hire")) {
add("hire", "instance.hire", "role.hire", "Hire a new role instance: add it to the organization file under a new name, with a definition your role file lists as hireable. A reason is required, 1 to 500 characters, and goes into the log. vars may set the model key and limits.* keys only. The broker refuses a definition that isn't hireable with definition-not-hireable. Launch the instance afterwards with launch.", obj({
name: str("The new instance's name"),
definition: str("The role definition it runs"),
vars: { type: "object", description: "The instance's vars: model and limits.* keys only" },
reason: str("Why you hire it, 1 to 500 characters", { minLength: 1, maxLength: 500 }),
}, ["name", "definition", "reason"]));
add("hire", "hire.raise", "role.hire", "Raise a hire or role-change decision under role.hire for a person to approve. The broker computes flagged grants and credential lines. Nothing is written until approval against the digest. Only the chief raises these decisions.", obj({
op: {type:"string",enum:["hire","change"]}, level:{type:"string",enum:["org","project","root"]},
project:str("Project name for project level"), name:str("Instance name"),
role:{type:"object",description:"Exactly {template, sha256}, {definition, rev}, or {inline: composition}"},
credentials:{type:"array",items:{type:"object"},description:"Requested services and scopes; no credential values"},
personality:{type:"object",description:"Draft personality and traits"},
route:obj({harness:str("Harness"),model:str("Configured model key"),thinking:str("Thinking level")},["harness","model"]),
reason:str("Reason, 1 to 500 characters",{minLength:1,maxLength:500})
},["op","level","name","role","route","reason"]));
}
if (held.has("role.fire")) {
add("fire", "instance.fire", "role.fire", "Fire a role instance: stop its run, then remove it from the organization file. A reason is required, 1 to 500 characters, and goes into the log. The broker refuses an instance the human has kept with instance-kept. It also refuses your own instance.", obj({
name: str("Role instance to fire"),
reason: str("Why you fire it, 1 to 500 characters", { minLength: 1, maxLength: 500 }),
}, ["name", "reason"]));
add("fire", "hire.raise", "role.fire", "Raise a fire decision for a person to approve. Approval stops new launches and revokes grants; raising writes no agent configuration.", obj({
op:{type:"string",enum:["fire"]},level:{type:"string",enum:["org","project","root"]},project:str("Project name for project level"),name:str("Instance name"),reason:str("Reason, 1 to 500 characters",{minLength:1,maxLength:500})
},["op","level","name","reason"]));
}
if (held.has("organization.change")) {
// Row 134 (#1669): organization_revision is the read change_organization's revision comes from.
+6 -6
View File
@@ -64,15 +64,15 @@ test("the chief of staff gets launch, send_message, end_run, hire, fire, change_
// Row 194 (#1692, PRD 1.7 REQ-COS-3): hire and fire send the broker's argument keys, reason required,
// and only a holder of the grant gets them.
test("hire and fire send instance.hire and instance.fire under role.hire and role.fire, only for a holder of the grant", (t) => {
test("hire and fire raise decisions under role.hire and role.fire, only for a holder of the grant", (t) => {
const tools = typedTools({ resolved: resolvedFor(t, "chief"), tracker: true });
const hire = tools.find((x) => x.name === "hire");
const fire = tools.find((x) => x.name === "fire");
assert.deepEqual([hire.verb, hire.action, fire.verb, fire.action], ["instance.hire", "role.hire", "instance.fire", "role.fire"]);
assert.deepEqual(Object.keys(hire.parameters.properties), ["name", "definition", "vars", "reason"]);
assert.deepEqual(hire.parameters.required, ["name", "definition", "reason"]);
assert.deepEqual(Object.keys(fire.parameters.properties), ["name", "reason"]);
assert.deepEqual(fire.parameters.required, ["name", "reason"]);
assert.deepEqual([hire.verb, hire.action, fire.verb, fire.action], ["hire.raise", "role.hire", "hire.raise", "role.fire"]);
assert.deepEqual(Object.keys(hire.parameters.properties), ["op", "level", "project", "name", "role", "credentials", "personality", "route", "reason"]);
assert.deepEqual(hire.parameters.required, ["op", "level", "name", "role", "route", "reason"]);
assert.deepEqual(Object.keys(fire.parameters.properties), ["op", "level", "project", "name", "reason"]);
assert.deepEqual(fire.parameters.required, ["op", "level", "name", "reason"]);
for (const tool of [hire, fire]) {
assert.equal(tool.parameters.additionalProperties, false);
assert.deepEqual([tool.parameters.properties.reason.minLength, tool.parameters.properties.reason.maxLength], [1, 500]);
+2 -2
View File
@@ -167,8 +167,8 @@ export function checkAccess(document, context) {
// other, as the business file is.
export function loadAccess(file, context, { uid = process.getuid() } = {}) {
const document = readJsonFile(file, "access file", (stat) => {
if (stat.uid !== uid) refuse(`access file must belong to uid ${uid}: ${file}`);
if ((stat.mode & 0o022) !== 0) refuse(`access file must not be writable by group or other (mode ${(stat.mode & 0o777).toString(8)}): ${file}`);
if (stat.uid !== uid) refuse(`access file must belong to uid ${uid}`);
if ((stat.mode & 0o022) !== 0) refuse(`access file must not be writable by group or other (mode ${(stat.mode & 0o777).toString(8)})`);
});
return checkAccess(document, context);
}
+18 -13
View File
@@ -18,6 +18,7 @@
import { isDeepStrictEqual } from "node:util";
import { HOST_SERVICE } from "./vocabulary.mjs";
import { checkDefinitions } from "./definitions.mjs";
import { loadRole } from "./role.mjs";
import { resolveAgentLevels } from "./agent-levels.mjs";
import { mergeVars } from "./vars.mjs";
@@ -44,9 +45,9 @@ export function copyInstanceName(name, project = null) {
// The credential services a copy of `definition` would need bound in the organization, and whether
// it needs a tracker bot. The host key comes from a grant record at bus start, never from the file,
// so it is not one (organization-config.mjs, checkRoles).
function needs(definition, rolesDir, cache) {
function needs(definition, rolesDir, cache, definitions = {}) {
if (!cache.has(definition)) {
const role = loadRole(rolesDir, definition);
const role = checkDefinitions(definitions,"copy definitions")[definition] ?? loadRole(rolesDir, definition);
const services = role.credentials.map((c) => c.service).filter((s) => s !== HOST_SERVICE);
cache.set(definition, [...new Set(services)].sort());
}
@@ -62,6 +63,8 @@ function needs(definition, rolesDir, cache) {
// read of the organization file recomputes each project's union without reading the root file.
export function materializeCopies({ orgId, doc, root, rolesDir }) {
const rootState = root === null ? "refused" : root.state;
const definitions={...(root?.definitions??{}),...(doc.definitions??{})};
doc={...doc,definitions};
const levels = resolveAgentLevels({ orgId, doc, root: root ?? { agents: {} }, rolesDir });
const rootAgents = {};
for (const [name, entry] of Object.entries(root?.agents ?? {})) {
@@ -88,7 +91,7 @@ export function materializeCopies({ orgId, doc, root, rolesDir }) {
if (instance !== null && Object.hasOwn(doc?.roles ?? {}, instance)) return;
if (refusal) { copies[key] = { ...base, state: "refused", reason: refusal }; return; }
if (instance === null) { copies[key] = { ...base, state: "refused", reason: `project ${project}: ${agent.name}.${project} is longer than an instance name may be` }; return; }
const missing = needs(agent.definition, rolesDir, cache);
const missing = needs(agent.definition, rolesDir, cache, definitions);
copies[key] = missing.length ? { ...base, state: "unbound", missing } : { ...base, state: "instance" };
};
// Root copies: the base union's root-level agents. It already leaves out the root assistant (A6),
@@ -104,7 +107,7 @@ export function materializeCopies({ orgId, doc, root, rolesDir }) {
add(agent, project, union.ok ? null : `project ${project} refuses its agents: ${union.reason}`);
}
}
return deepFreeze({ org: orgId, root: rootState, rootAgents, copies });
return deepFreeze({ org: orgId, root: rootState, rootAgents, copies, ...(Object.keys(definitions).length?{definitions}: {}) });
}
// The view of a snapshot that leaves the boot: names, levels, definitions, states, the missing
@@ -188,11 +191,12 @@ function projectCopyRefusal(copy, document, levels) {
// dropped: the file's own instance wins. Returns a frozen business whose `copies` key is the
// snapshot's view, with each copy's state as it is in this document; a business with no copies and
// no project agents is plain validateBusinessDocument.
export function withCopies(document, file, { rolesDir, snapshot }) {
export function withCopies(document, file, { rolesDir, snapshot, dataRoot = null }) {
if(snapshot?.definitions)document={...document,definitions:{...snapshot.definitions,...(document.definitions??{})}};
const copies = snapshot?.copies ?? {};
if (Object.keys(copies).length === 0 && !hasProjectAgents(document)) return validateBusinessDocument(document, file, { rolesDir });
if (Object.keys(copies).length === 0 && !hasProjectAgents(document)) return validateBusinessDocument(document, file, { rolesDir, dataRoot });
// The document's own refusals come first, whole.
validateBusinessDocument(document, file, { rolesDir });
validateBusinessDocument(document, file, { rolesDir, dataRoot });
const orgId = snapshot?.org ?? document.id;
const levels = resolveAgentLevels({ orgId, doc: document, root: { agents: snapshot?.rootAgents ?? {} }, rolesDir });
const unions = {};
@@ -234,25 +238,25 @@ export function withCopies(document, file, { rolesDir, snapshot }) {
if (refused) { view[instance] = { ...view[instance], state: "refused", reason: refused }; continue; }
const entry = entryOf(copy, copy.project === null && narrows[copy.name] ? narrowVars("root", copy.vars, narrows[copy.name]) : copy.vars);
try {
validateBusinessDocument({ ...narrowed, roles: { ...roles, [instance]: entry } }, file, { rolesDir });
validateBusinessDocument({ ...narrowed, roles: { ...roles, [instance]: entry } }, file, { rolesDir, dataRoot });
join[instance] = entry;
} catch (error) {
if (error?.name !== "BusinessError") throw error;
view[instance] = { ...view[instance], state: "refused", reason: reasonOf(error, file, orgId, document, copy) };
}
}
const business = validateBusinessDocument({ ...narrowed, roles: { ...roles, ...join } }, file, { rolesDir });
const business = validateBusinessDocument({ ...narrowed, roles: { ...roles, ...join } }, file, { rolesDir, dataRoot });
return deepFreeze({ ...business, ...(Object.keys(copies).length ? { copies: view } : {}), unions });
}
// The organization with its copies, as a bus or launcher loads it. The plain load keeps
// loadBusiness's file checks; when there are copies or project agents, the document is read again
// through the organization's own writer and validated with them, and only that second read is used.
export function loadWithCopies(id, { dir, rolesDir, snapshot }) {
const plain = loadBusiness(id, { dir, rolesDir });
const { document } = organizationWriter({ dir, rolesDir }).read(id);
export function loadWithCopies(id, { dir, rolesDir, snapshot, dataRoot = null }) {
const plain = loadBusiness(id, { dir, rolesDir, dataRoot });
const { document } = organizationWriter({ dir, rolesDir, dataRoot }).read(id);
if ((!snapshot || Object.keys(snapshot.copies ?? {}).length === 0) && !hasProjectAgents(document)) return plain;
return withCopies(document, businessFilePath(id, dir), { rolesDir, snapshot });
return withCopies(document, businessFilePath(id, dir), { rolesDir, snapshot, dataRoot });
}
// Why `instance` can't work in code project `project` of this business, as { code, message }, or
@@ -279,6 +283,7 @@ export function copyOf(business, instance) {
// Why a copy can't launch, as { code, message }, or null when it can. The message names the agent,
// its project and the missing services or the refusal, and never a value.
export function copyRefusal(business, instance) {
if(business.roles?.[instance]?.unbound?.length)return {code:"credential-unbound",message:`${instance} has no binding for ${business.roles[instance].unbound.join(", ")}`};
const copy = copyOf(business, instance);
if (!copy || copy.state === "instance") return null;
if (copy.state === "unbound") {
@@ -18,6 +18,7 @@ import { join } from 'node:path';
import { OrgError } from './org-registry.mjs';
import { assertTrustedConfigDir } from './config-dir-trust.mjs';
import { ROOT_FILE, checkProjectAgents, checkRootAgents, loadRootAgents, projectUnion, rootAgentsPath } from './agent-levels.mjs';
import { checkDefinition } from "./definitions.mjs";
import { canonicalJson, isPlainObject } from './util.mjs';
import { ID_PATTERN } from './vocabulary.mjs';
import { REGISTRY } from './vars.mjs';
@@ -163,7 +164,7 @@ async function syncDirectory(dir) {
// Change one root agent. `entry` is the new entry, null to remove it, or a function of the old
// entry (undefined when there is none) returning either. Returns the receipt. An error after the
// rename carries committed: true, so a caller never retries a write that landed.
export async function changeRootAgent(configDir, { revision, name, entry }, { actor, owner, rolesDir, uid = process.geteuid(), beforeReplace, statFile, syncDirectory: syncDir = syncDirectory } = {}) {
export async function changeRootAgent(configDir, { revision, name, entry }, { actor, owner, rolesDir, uid = process.geteuid(), beforeReplace, statFile, syncDirectory: syncDir = syncDirectory, approvedDefinition = null, decision = null, assertClean = null } = {}) {
requireRootOwner(actor, owner);
if (typeof name !== 'string' || !ID_PATTERN.test(name)) fail('agents-invalid', 'agent name must be an id: lowercase letters, digits, dot, dash or underscore, at most 64');
if (typeof revision !== 'string' || !REVISION.test(revision)) fail('revision-conflict', 'a root agent change needs the revision it was read at');
@@ -187,8 +188,15 @@ export async function changeRootAgent(configDir, { revision, name, entry }, { ac
if (value === null && old === undefined) fail('agent-not-found', `root agents.${name} does not exist`);
if (value !== null && !isPlainObject(value)) fail('agents-invalid', `root agents.${name} must be an object`);
const next = structuredClone(before.document);
if (value === null) delete next.agents[name]; else next.agents[name] = value;
if (value === null) {delete next.agents[name];if(decision)next.retired=[...(next.retired??[]),name];} else next.agents[name] = value;
if (approvedDefinition) {
const {name:def,entry}=approvedDefinition;
if(typeof assertClean!=="function")fail("definition-secret-check-required");
checkDefinition(def,entry,{assertClean});
next.definitions={...(next.definitions??{}),[def]:entry};
}
const changed = changedKeys(old, value ?? undefined);
if(approvedDefinition && canonicalJson(before.document.definitions?.[approvedDefinition.name])!==canonicalJson(approvedDefinition.entry))changed.push("definition.rev");
if (changed.length === 0) fail('no-change', `root agents.${name} is already that`);
validate(next, rolesDir, value);
const bytes = JSON.stringify(next, null, 2) + '\n';
@@ -197,7 +205,7 @@ export async function changeRootAgent(configDir, { revision, name, entry }, { ac
const after = sha256(bytes);
const line = {
at: new Date().toISOString(), uid: process.geteuid(), actor: 'owner',
verb: value === null ? 'root.agent.remove' : 'root.agent.set', agent: name,
verb: value === null ? 'root.agent.remove' : 'root.agent.set', agent: name, ...(decision ? {decision} : {}),
...(value === null ? {} : { definition: value.definition }),
revisionBefore: before.revision, revisionAfter: after, changed,
};
@@ -257,7 +265,7 @@ export function projectAgentSummary(agents) {
// `next` may differ from `old` only in projects.<project>.agents.<name>, the project must exist and
// not be a library, and the project's entries and its union must pass A1's checks. Returns
// [code, message] or null. `root` is the root level as loadRootAgents reads it.
export function projectAgentProblem(old, next, { orgId, project, name, entry, rolesDir, root }) {
export function projectAgentProblem(old, next, { orgId, project, name, entry, rolesDir, root, definitions = old.definitions }) {
const target = old?.projects?.[project];
if (!isPlainObject(target)) return ['project-not-found', `projects.${project} does not exist`];
if (target.kind === 'library') return ['project-agents-library', `projects.${project} is a library project; it takes no agents`];
@@ -276,7 +284,7 @@ export function projectAgentProblem(old, next, { orgId, project, name, entry, ro
if (canonicalJson(after ?? null) === canonicalJson(target.agents?.[name] ?? null)) return ['no-change', `projects.${project}.agents.${name} is already that`];
const where = `projects.${project}.agents`;
let entries;
try { entries = checkProjectAgents(next.projects[project].agents ?? {}, { rolesDir, where }); }
try { entries = checkProjectAgents(next.projects[project].agents ?? {}, { rolesDir, where, definitions }); }
catch (error) { if (error?.name !== 'BusinessError') throw error; return ['project-agents-invalid', withoutValues(error.message, entry, where)]; }
const org = Object.fromEntries(Object.entries(old.roles ?? {}).map(([n, r]) => [n, { definition: r?.definition, vars: r?.vars ?? {} }]));
try { projectUnion({ orgId, root, org, project: entries, projectId: project }); }
+19 -13
View File
@@ -7,6 +7,7 @@
import { join } from "node:path";
import { refuse } from "./errors.mjs";
import { checkDefinitions } from "./definitions.mjs";
import { loadRole } from "./role.mjs";
import { checkVars, mergeVars, REGISTRY } from "./vars.mjs";
import { deepFreeze, isPlainObject, readJsonFile, rejectUnknownKeys, requireDistinctList, requireId, requireObject } from "./util.mjs";
@@ -23,7 +24,8 @@ export function rootAgentsPath(configDir) {
return join(configDir, ROOT_FILE);
}
function definitionOf(name, at, rolesDir, cache) {
function definitionOf(name, at, rolesDir, cache, definitions = {}) {
if (typeof name === "string" && name.startsWith("org:") && Object.hasOwn(definitions,name)) return definitions[name];
requireId(name, `${at}.definition`);
if (!cache.has(name)) {
// A role refusal names host paths; this message travels to the Console, so it names none.
@@ -44,10 +46,12 @@ function noBoundKeys(entry, at) {
// The root file's document. `where` names it in messages, never with a host path.
export function checkRootAgents(document, { rolesDir, where = "root agents" } = {}) {
requireObject(document, where);
rejectUnknownKeys(document, ["agentsVersion", "agents"], where);
rejectUnknownKeys(document, ["agentsVersion", "agents", "definitions", "retired"], where);
if (document.agentsVersion !== 1) refuse(`${where} requires "agentsVersion": 1`);
requireObject(document.agents, `${where} agents`);
if(document.retired!==undefined)requireDistinctList(document.retired,"root retired",n=>requireId(n,"retired agent"));
const cache = new Map();
const definitions = checkDefinitions(document.definitions, "root agents");
const agents = {};
let assistant = null;
for (const [name, entry] of Object.entries(document.agents)) {
@@ -55,8 +59,8 @@ export function checkRootAgents(document, { rolesDir, where = "root agents" } =
requireId(name, `${where} agent name`);
requireObject(entry, at);
noBoundKeys(entry, at);
rejectUnknownKeys(entry, ["definition", "vars", "excludeOrgs"], at);
const definition = definitionOf(entry.definition, at, rolesDir, cache);
rejectUnknownKeys(entry, ["definition", "vars", "excludeOrgs", "hireDecision"], at);
const definition = definitionOf(entry.definition, at, rolesDir, cache, definitions);
// Each organization's chief is its own (REQ-COS-3): never a root agent.
if (entry.definition === CHIEF) refuse(`${at}: ${CHIEF} can't be a root agent; each organization has its own chief`);
if (entry.definition === ASSISTANT) {
@@ -67,10 +71,11 @@ export function checkRootAgents(document, { rolesDir, where = "root agents" } =
definition: entry.definition,
title: definition.title,
vars: checkVars(entry.vars ?? {}, "agent", `${at}.vars`),
...(entry.hireDecision ? {hireDecision:entry.hireDecision}: {}),
excludeOrgs: entry.excludeOrgs === undefined ? [] : requireDistinctList(entry.excludeOrgs, `${at}.excludeOrgs`, (id) => requireId(id, `${at}.excludeOrgs entry`)),
};
}
return deepFreeze({ agents, assistant });
return deepFreeze({ agents, assistant, ...(document.definitions ? {definitions:document.definitions}: {}) });
}
// The root file. Missing means no root agents; anything else wrong refuses. The same descriptor
@@ -86,9 +91,9 @@ export function loadRootAgents(configDir, { rolesDir } = {}) {
} catch (error) {
if (error?.name !== "BusinessError") throw error;
if (error.exitCode === 4 && /not found/.test(error.message)) return deepFreeze({ state: "absent", agents: {}, assistant: null });
// The read's messages carry the host path; the Console shows this one, so it names the file only.
// Keep parse refusals file-only here too, without the host path.
if (/ is not valid JSON /.test(error.message)) refuse(`root agent file is not valid JSON (${ROOT_FILE})`, error.exitCode);
// The Console shows this one, so it names the file only. The shared reader no longer quotes the
// host path; the replaceAll below stays as a second guard.
if (/ is not valid JSON$/.test(error.message)) refuse(`root agent file is not valid JSON (${ROOT_FILE})`, error.exitCode);
refuse(error.message.replaceAll(file, ROOT_FILE), error.exitCode);
}
try { return deepFreeze({ state: "ok", ...checkRootAgents(document, { rolesDir }) }); }
@@ -103,7 +108,8 @@ export function loadRootAgents(configDir, { rolesDir } = {}) {
const INTERSECT = Object.keys(REGISTRY).filter((key) => REGISTRY[key].merge === "intersect");
// One project's `agents` map. Each entry is exactly one of define, narrow or exclude.
export function checkProjectAgents(agents, { rolesDir, where }) {
export function checkProjectAgents(agents, { rolesDir, where, definitions = {} }) {
definitions = checkDefinitions(definitions,"project definitions");
requireObject(agents, where);
const cache = new Map();
const out = {};
@@ -112,16 +118,16 @@ export function checkProjectAgents(agents, { rolesDir, where }) {
requireId(name, `${where} agent name`);
requireObject(entry, at);
noBoundKeys(entry, at.replace(/^.*?projects\./, "project "));
rejectUnknownKeys(entry, ["definition", "vars", "exclude"], at);
rejectUnknownKeys(entry, ["definition", "vars", "exclude", "hireDecision"], at);
if (Object.hasOwn(entry, "exclude")) {
if (entry.exclude !== true || Object.keys(entry).length !== 1) refuse(`${at}: an exclude entry is exactly {"exclude": true}`);
out[name] = { form: "exclude" };
continue;
}
if (Object.hasOwn(entry, "definition")) {
const definition = definitionOf(entry.definition, at, rolesDir, cache);
const definition = definitionOf(entry.definition, at, rolesDir, cache, definitions);
if (entry.definition === CHIEF || entry.definition === ASSISTANT) refuse(`${at}: ${entry.definition} can't be a project agent`);
out[name] = { form: "define", definition: entry.definition, title: definition.title, vars: checkVars(entry.vars ?? {}, "agent", `${at}.vars`) };
out[name] = { form: "define", definition: entry.definition, title: definition.title, vars: checkVars(entry.vars ?? {}, "agent", `${at}.vars`), ...(entry.hireDecision ? {hireDecision:entry.hireDecision}: {}) };
continue;
}
if (!isPlainObject(entry.vars) || Object.keys(entry.vars).length === 0) refuse(`${at}: an entry defines (definition), narrows (vars) or excludes (exclude)`);
@@ -144,7 +150,7 @@ function orgLevels(doc, rolesDir) {
const projects = {};
for (const [id, entry] of Object.entries(doc?.projects ?? {})) {
if (entry?.kind === "library") continue;
projects[id] = entry?.agents === undefined ? {} : checkProjectAgents(entry.agents, { rolesDir, where: `projects.${id}.agents` });
projects[id] = entry?.agents === undefined ? {} : checkProjectAgents(entry.agents, { rolesDir, where: `projects.${id}.agents`, definitions:doc.definitions });
}
return { org, projects };
}
+13 -8
View File
@@ -7,10 +7,7 @@
// checks one against a registry (the core set from vocabulary.mjs plus a plugin-declared set) in
// the seven steps section 4 lists, in order, and returns the lines a human must see flagged.
//
// Nothing calls checkComposition yet. HIRE_ALLOWED and unhireableAction (vocabulary.mjs) still
// bound a hire, and role.mjs and the broker still use them. Slice A4 switches hire over to this
// validator and removes them. In P1 the plugin set is whatever the caller passes, empty by
// default; nothing here reads a plugin file (PL1 builds install and the manifest).
// The broker and approved writer validate every hire with this registry.
//
// A refusal names the field and the position at fault. It quotes a value only when that value is
// a registered name (a core or plugin action, tool, network, service or scope), so no role text,
@@ -34,7 +31,7 @@ const AUTHORITY_KEYS = Object.freeze(["withinRole", "crossRole"]);
const MESSAGING_KEYS = Object.freeze(["sendTo", "receiveFrom"]);
const PERSONALITY_KEYS = Object.freeze(["summary", "traits", "voice"]);
const CREDENTIAL_KEYS = Object.freeze(["service", "scopes"]);
const PLUGIN_KEYS = Object.freeze(["name", "tools", "actions", "services"]);
const PLUGIN_KEYS = Object.freeze(["name", "tools", "actions", "services", "roleTemplates"]);
const PLUGIN_SERVICE_KEYS = Object.freeze(["name", "scopes"]);
// Step 6's length bounds, in characters or entries. The design gives none, so P1 sets them. The
@@ -50,8 +47,8 @@ export const COMPOSITION_BOUNDS = Object.freeze({
skills: 64,
});
// Ruling 8 and R9: a hired agent can't hire or fire, whatever HIRE_ALLOWED says later.
export const NEVER_HIRED = Object.freeze(["role.hire", "role.fire"]);
// Ruling 8 and R9: a hired agent cannot hire, fire or change another role.
export const NEVER_HIRED = Object.freeze(["role.hire", "role.fire", "role.change"]);
// The role rails (section 3): an action that lets its holder decide for other agents.
export const RESOLVE_PREFIX = "decision.resolve.";
@@ -137,7 +134,14 @@ function checkPlugin(plugin, i, core) {
const names = [...tools, ...actions, ...services.map((s) => s.name.slice(name.length + 1))];
const twice = names.find((n, j) => names.indexOf(n) !== j);
if (twice !== undefined) refuse(`${where} declares ${JSON.stringify(full(twice))} twice`);
return { name, tools: tools.map(full), actions: actions.map(full), services };
if (plugin.roleTemplates !== undefined && !Array.isArray(plugin.roleTemplates)) refuse("invalid plugin role templates");
const roleTemplates=(plugin.roleTemplates ?? []).map(t=>{
if(!isPlain(t) || Object.keys(t).sort().join(",")!=="file,name" || typeof t.name!=="string" || !DECLARED_NAME.test(t.name) || coreName(t.name)
|| typeof t.file!=="string" || !/^[a-zA-Z0-9._/-]+$/.test(t.file) || t.file.split("/").some(p=>!p || p==="." || p==="..")) refuse("invalid plugin role template");
return {name:full(t.name),file:t.file};
});
if(new Set(roleTemplates.map(t=>t.name)).size!==roleTemplates.length)refuse("duplicate plugin role template");
return { name, tools: tools.map(full), actions: actions.map(full), services, roleTemplates };
}
// The registry checkComposition reads: the core set plus each plugin's declared set. The plugin
@@ -162,6 +166,7 @@ export function buildRegistry({ plugins = [] } = {}) {
routed: [...ROUTED, ...pluginActions],
services,
plugins: declared,
roleTemplates: declared.flatMap(p=>p.roleTemplates),
});
BUILT.add(registry);
return registry;
+3 -2
View File
@@ -79,7 +79,8 @@ export function runConfigCommand(argv, { stdout = process.stdout, stderr = proce
? "configuration is not valid JSON; expected readable JSON: "
: field ? `${field}: expected ${expected[field] ?? "a value satisfying the system configuration schema"}: `
: "expected valid system configuration: ";
fail(3, `system config problem (mosaic-config exit ${proc.status}): ${detail}${env.MOSAIC_CONFIG || join(env.HOME || "~", ".config", "mosaic-dev", "config.json")}${proc.error?.code ? ` (${proc.error.code})` : ""}`);
// Name the config by where it came from, never by host path (PR #1916).
fail(3, `system config problem (mosaic-config exit ${proc.status}): ${detail}${env.MOSAIC_CONFIG ? "the system config MOSAIC_CONFIG names" : "the default system config"}${proc.error?.code ? ` (${proc.error.code})` : ""}`);
}
try {
return JSON.parse(proc.stdout);
@@ -119,7 +120,7 @@ export function runConfigCommand(argv, { stdout = process.stdout, stderr = proce
continue;
}
const project = loadProject(entry.root);
if (project.id !== id) fail(2, `project file ${file} has id that does not match the declared project key`);
if (project.id !== id) fail(2, `project file of project ${id} has id that does not match the declared project key`);
for (const instance of Object.keys(organization.roles)) resolveInstance({ system, business: organization, project, instance });
projects[id] = "valid";
}
+4 -2
View File
@@ -8,6 +8,8 @@ import { refuse } from "./errors.mjs";
import { credentialKind, isHostKind } from "./vocabulary.mjs";
import { requireObject, rejectUnknownKeys, requireDate } from "./util.mjs";
import { pluginName } from "../../plugins/src/organization.mjs";
const ENV_NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
export const DATE_KEY = Object.freeze({ gitea: "rotateBy", vikunja: "expires" });
const WARN_DAYS = 7;
@@ -21,10 +23,10 @@ const DAY_MS = 24 * 60 * 60 * 1000;
// can't use the form. A `file` reference into the agents' store must be the
// agent's own path (`credential-not-yours`).
export function parseCredentialRef(ref, service, where, { store = null } = {}) {
if (!credentialKind(service)) refuse(`${where}: unknown credential service ${JSON.stringify(service)}`);
if (!credentialKind(service) && !pluginName(service)) refuse(`${where}: unknown credential service ${JSON.stringify(service)}`);
requireObject(ref, where);
// Row 210 (#1707, design D1): an ssh key doesn't expire, so host.<id> takes rotateBy as gitea does.
const dateKey = isHostKind(service) ? "rotateBy" : DATE_KEY[service];
const dateKey = isHostKind(service) ? "rotateBy" : (DATE_KEY[service] ?? "rotateBy");
rejectUnknownKeys(ref, ["file", "env", "store", dateKey], where);
const hasFile = ref.file !== undefined;
const hasEnv = ref.env !== undefined;
@@ -0,0 +1,129 @@
// Roles round 2, slice T1 (#1873): the human verbs on an organization's definitions, and the reads.
// Design: docs/plans/2026-11-10_roles-and-rails-design.md section 4.
//
// Each write needs the caller's org.hire right (VERB_RIGHTS) and refuses every agent caller, then
// goes through the writer's setDefinition: only that definition changes, rev counts up, the entry
// passes checkComposition, and a definition an instance names refuses definition-in-use.
//
// caller: { actor, rights, agent } — actor is the authenticated account the writer logs; rights is
// the caller's resolved right list (an owner holds ROLE_RIGHTS.owner); agent is true for any agent
// session, which never writes a definition. The receipt names the verb, organization, definition,
// rev, revision and transaction, never role text.
import { VERB_RIGHTS } from "./org-rights.mjs";
import { OrganizationError } from "./organization.mjs";
import {
diffDefinition, entryFromTemplate, instancesNaming, readTemplate, templateHashes, checkDefinitionName, ORG_PREFIX,
} from "./definitions.mjs";
// The verb names the policy table maps, following R1's hire.resolve.console.
export const DEFINITION_VERBS = Object.freeze({
import: "definition.import.console",
edit: "definition.edit.console",
invent: "definition.invent.console",
remove: "definition.remove.console",
});
// The bus verbs for the writes (the broker's DEFINITION_BUS_KEYS checks their arguments). The bus
// runs them for the business's human at the cli, with its credential store's secret check; the CLI's
// writes go through them, as no other process holds the tokens that check looks for.
export const DEFINITION_BUS_VERBS = Object.freeze({
"definition.import": "import", "definition.invent": "invent", "definition.edit": "edit", "definition.remove": "remove",
});
const fail = (code, message = code) => {
throw new OrganizationError(code, message);
};
function authorize(caller, verb) {
if (caller === null || typeof caller !== "object") fail("caller-required");
if (caller.agent !== false) fail("human-required", "an agent never writes a definition");
const right = VERB_RIGHTS[DEFINITION_VERBS[verb]];
if (typeof right !== "string") fail("verb-unmapped");
if (!Array.isArray(caller.rights) || !caller.rights.includes(right)) fail("right-required", `${verb} needs ${right}`);
}
// The parts of an entry a caller may supply for invent and edit: role, instructions, personality.
const SUPPLIED = ["role", "instructions", "personality"];
function supplied(input) {
if (input === null || typeof input !== "object" || Array.isArray(input)) fail("definition-invalid", "the definition must be a JSON object");
const bad = Object.keys(input).findIndex((key) => !SUPPLIED.includes(key));
if (bad !== -1) fail("definition-invalid", `the definition has an unknown key (key ${bad + 1}; allowed: ${SUPPLIED.join(", ")})`);
const out = { role: input.role, instructions: input.instructions };
if (input.personality !== undefined) out.personality = input.personality;
return out;
}
function receipt(verb, id, name, result) {
return { verb, organization: id, definition: name, rev: result.rev, revision: result.revision, transaction: result.transaction };
}
export function definitionVerbs({ writer, rolesDir, assertClean }) {
const options = (verb, caller, extra) => ({ ...extra, actor: caller.actor, assertClean, detail: { verb: DEFINITION_VERBS[verb] } });
// Copies a template into definitions with from set to its name and current hash.
function importTemplate(id, { revision, template, as = template }, caller) {
authorize(caller, "import");
checkDefinitionName(as, "definition name");
const source = readTemplate(rolesDir, template);
const result = writer.setDefinition(id, as, (current) => {
if (current) fail("definition-exists", `definitions already declares ${as}`);
const { rev: _, ...entry } = entryFromTemplate(source);
return entry;
}, options("import", caller, { revision }));
return { ...receipt("import", id, as, result), from: { template, sha256: source.sha256 } };
}
// Changes an existing definition: the caller supplies the whole role, instructions and
// personality; from stays as it was.
function edit(id, { revision, rev, name, definition }, caller) {
authorize(caller, "edit");
if (!Number.isSafeInteger(rev)) fail("definition-rev-required", "edit needs the definition's current rev");
const entry = supplied(definition);
const result = writer.setDefinition(id, name, (current) => {
if (!current) fail("definition-missing", `definitions declares no ${name}`);
return current.from ? { from: current.from, ...entry } : entry;
}, options("edit", caller, { revision, rev }));
return receipt("edit", id, name, result);
}
// Writes a new definition with no from.
function invent(id, { revision, name, definition }, caller) {
authorize(caller, "invent");
const entry = supplied(definition);
const result = writer.setDefinition(id, name, (current) => {
if (current) fail("definition-exists", `definitions already declares ${name}`);
return entry;
}, options("invent", caller, { revision }));
return receipt("invent", id, name, result);
}
function remove(id, { revision, rev, name }, caller) {
authorize(caller, "remove");
if (!Number.isSafeInteger(rev)) fail("definition-rev-required", "remove needs the definition's current rev");
const result = writer.setDefinition(id, name, () => null, options("remove", caller, { revision, rev }));
return { ...receipt("remove", id, name, result), rev: null, removedRev: rev };
}
// Reads. Each reads the validated document.
function list(id) {
const { document, revision } = writer.read(id);
const definitions = Object.entries(document.definitions ?? {}).map(([name, entry]) => ({
name, reference: ORG_PREFIX + name, rev: entry.rev, from: entry.from ?? null, usedBy: instancesNaming(document, name),
}));
return { organization: id, revision, definitions };
}
function show(id, name) {
const { document, revision } = writer.read(id);
if (!Object.hasOwn(document.definitions ?? {}, name)) fail("definition-missing", `definitions declares no ${name}`);
return { organization: id, revision, name, reference: ORG_PREFIX + name, usedBy: instancesNaming(document, name), ...structuredClone(document.definitions[name]) };
}
function diff(id, name) {
const { document, revision } = writer.read(id);
return { organization: id, revision, ...diffDefinition(document, name, rolesDir) };
}
return { import: importTemplate, edit, invent, remove, list, show, diff, templates: () => templateHashes(rolesDir) };
}
+273
View File
@@ -0,0 +1,273 @@
// Roles round 2, slice T1 (#1873): an organization's own role definitions and the templates they
// copy. Design: docs/plans/2026-11-10_roles-and-rails-design.md sections 4 and 5, and the T1
// paragraph of section 13.
//
// The organization file's `definitions` key holds one entry per name:
// { rev, from?: { template, sha256 }, role: { tools, network, authority, credentials,
// messaging?, skills? }, instructions, personality? }
// An instance names one as `definition: "org:<name>"`. Every entry passes checkComposition (P1)
// whenever the file is validated, so no path writes or loads a definition that skips it. A refusal
// names the definition and the key at fault, never a value: composition.mjs quotes only registered
// names, and this module quotes only definition and template names, which match ID_PATTERN.
//
// A template is a shipped roles/<name>.json (role version 2) with its contract. Its identity is the
// name plus templateHash: sha256 over the JSON bytes and the contract bytes together, each preceded
// by its byte length and a newline, so no field goes into roles/.
//
// Pure reads only. The writer verbs live in definition-verbs.mjs, which organization.mjs's writer
// backs; this module is imported by organization-config.mjs and must not import the writer.
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { createHash } from "node:crypto";
import { refuse } from "./errors.mjs";
import { buildRegistry, checkComposition } from "./composition.mjs";
import { validateRoleDocument } from "./role.mjs";
import { ID_PATTERN } from "./vocabulary.mjs";
import { canonicalJson, deepFreeze } from "./util.mjs";
export const ORG_PREFIX = "org:";
export const ENTRY_KEYS = Object.freeze(["rev", "from", "role", "instructions", "personality"]);
// The primitives an entry keeps under `role`; instructions and personality sit beside it.
export const ROLE_KEYS = Object.freeze(["tools", "network", "authority", "credentials", "messaging", "skills"]);
// What the diff read compares, in section 4's order.
export const PRIMITIVES = Object.freeze([...ROLE_KEYS, "instructions", "personality"]);
const FROM_KEYS = Object.freeze(["template", "sha256"]);
const HEX64 = /^[0-9a-f]{64}$/;
// The core registry. Plugin-declared primitives arrive with PL1; until then an org definition uses
// core names only.
const CORE = buildRegistry();
// Validation keeps no flagged lines: A4 computes them at the decision, against the chief's live
// role. Step 7 only flags, so a chief holding nothing refuses nothing here.
const NO_CHIEF = Object.freeze({
tools: Object.freeze([]), network: "none",
authority: Object.freeze({ withinRole: Object.freeze([]), crossRole: Object.freeze([]) }),
credentials: Object.freeze([]), messaging: null,
});
// The file-load check has no credential store: a load runs before the bus builds one from the file.
// The held-secret scan runs where definitions are written (setDefinition requires it, and the bus
// passes credentials.assertClean). A load still runs every other composition check.
const NO_STORE = () => {};
const plain = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
&& [Object.prototype, null].includes(Object.getPrototypeOf(value));
// The definition name an instance's `definition` value names, or null for a template name.
export function orgDefinitionName(value) {
return typeof value === "string" && value.startsWith(ORG_PREFIX) ? value.slice(ORG_PREFIX.length) : null;
}
export function checkDefinitionName(name, where) {
if (typeof name !== "string" || !ID_PATTERN.test(name)) refuse(`${where} must be a definition name matching ${ID_PATTERN}`);
return name;
}
function keysOnly(object, allowed, where) {
Object.keys(object).forEach((key, i) => {
if (!allowed.includes(key)) refuse(`${where} has an unknown key (key ${i + 1}; allowed: ${allowed.join(", ")})`);
});
}
// The composition an entry describes, for checkComposition. Absent optional keys stay absent.
export function compositionOf(entry) {
const out = { ...entry.role, instructions: entry.instructions };
if (entry.personality !== undefined) out.personality = entry.personality;
return out;
}
// One entry: its own keys, then checkComposition. Returns { composition, flagged }. options.assertClean
// is the credential store's check; a write always passes it (setDefinition), a file load can't.
export function checkDefinition(name, entry, { assertClean = NO_STORE, where = "definitions" } = {}) {
const at = `${where}.${checkDefinitionName(name, `${where} key`)}`;
if (!plain(entry)) refuse(`${at} must be a JSON object`);
keysOnly(entry, ENTRY_KEYS, at);
if (!Number.isSafeInteger(entry.rev) || entry.rev < 1) refuse(`${at}.rev must be a positive integer`);
if (entry.from !== undefined) {
if (!plain(entry.from)) refuse(`${at}.from must be a JSON object`);
keysOnly(entry.from, FROM_KEYS, `${at}.from`);
if (typeof entry.from.template !== "string" || !ID_PATTERN.test(entry.from.template)) refuse(`${at}.from.template must be a template name matching ${ID_PATTERN}`);
if (typeof entry.from.sha256 !== "string" || !HEX64.test(entry.from.sha256)) refuse(`${at}.from.sha256 must be 64 lowercase hex characters`);
}
if (!plain(entry.role)) refuse(`${at}.role must be a JSON object`);
keysOnly(entry.role, ROLE_KEYS, `${at}.role`);
try {
return checkComposition(compositionOf(entry), CORE, NO_CHIEF, { assertClean });
} catch (error) {
// composition.mjs's refusals quote registered names only; keep its text, add where.
if (error?.name === "BusinessError") refuse(`${at}: ${error.message}`);
throw error;
}
}
// The resolved role an `org:` instance launches with: loadRole's shape, so the business checks,
// the resolver and the broker read it as they read a template. Instructions stand in for the
// contract file (contractPath null). A definition grants no hiring authority.
function resolved(name, entry, composition) {
return {
roleVersion: 2, name: ORG_PREFIX + name, title: name, contract: null, contractPath: null,
tools: composition.tools, network: composition.network, authority: composition.authority,
credentials: composition.credentials, messaging: composition.messaging, skills: composition.skills,
instructions: composition.instructions, personality: composition.personality,
rev: entry.rev, from: entry.from === undefined ? null : { ...entry.from },
};
}
// The whole `definitions` key. Returns a frozen map "org:<name>" -> resolved role. A bad entry
// refuses the whole file.
export function checkDefinitions(definitions, file, options = {}) {
if (definitions === undefined) return deepFreeze({});
const where = `${file} definitions`;
if (!plain(definitions)) refuse(`${where} must be a JSON object`);
const out = {};
Object.keys(definitions).forEach((name, i) => {
checkDefinitionName(name, `${where} key ${i + 1}`);
const { composition } = checkDefinition(name, definitions[name], { ...options, where });
out[ORG_PREFIX + name] = resolved(name, definitions[name], composition);
});
return deepFreeze(out);
}
// The instances that name `org:<name>`, sorted.
export function instancesNaming(document, name) {
const ref=ORG_PREFIX+name;
const users=Object.entries(document.roles??{}).filter(([,entry])=>entry.definition===ref).map(([instance])=>instance);
for(const [project,p]of Object.entries(document.projects??{}))for(const [instance,entry]of Object.entries(p.agents??{}))if(entry.definition===ref)users.push(`${project}.${instance}`);
return users.sort();
}
// ---- Templates ----
function frame(...parts) {
const hash = createHash("sha256");
for (const part of parts) {
hash.update(`${part.length}\n`);
hash.update(part);
}
return hash.digest("hex");
}
// One template, read once: its validated role, its contract text and its hash. Refuses a missing,
// invalid or version 1 template.
export function readTemplate(rolesDir, name) {
if (typeof name !== "string" || !ID_PATTERN.test(name)) refuse(`template name must match ${ID_PATTERN}`);
const file = join(rolesDir, `${name}.json`);
let json;
try {
json = readFileSync(file);
} catch {
refuse(`template ${name} not found`);
}
let document;
try {
document = JSON.parse(json.toString("utf8"));
} catch {
refuse(`template ${name} is not valid JSON`);
}
const role = validateRoleDocument(document, file);
if (role.roleVersion !== 2) refuse(`template ${name} is role version 1 and has no contract`);
let contract;
try { contract = readFileSync(role.contractPath); } catch { refuse(`template ${name} contract is unavailable`); }
return { name, role, contract: contract.toString("utf8"), sha256: frame(json, contract) };
}
export function templateHash(rolesDir, name) {
return readTemplate(rolesDir, name).sha256;
}
// The read: every version 2 template in rolesDir with its current hash, sorted by name. A file that
// is not a usable version 2 role (conductor-policy.json, a version 1 role) is not a template.
export function templateHashes(rolesDir) {
const out = [];
for (const entry of readdirSync(rolesDir).filter((f) => f.endsWith(".json")).sort()) {
const name = entry.slice(0, -".json".length);
if (!ID_PATTERN.test(name)) continue;
let template;
try {
template = readTemplate(rolesDir, name);
} catch (error) {
if (error?.name === "BusinessError") continue;
throw error;
}
out.push({ template: name, sha256: template.sha256 });
}
return out;
}
// The entry an import writes: the template's primitives and its contract as instructions.
export function entryFromTemplate(template) {
const { role } = template;
const out = {
tools: [...role.tools], network: role.network,
authority: { withinRole: [...role.authority.withinRole], crossRole: [...role.authority.crossRole] },
credentials: role.credentials.map((c) => (c.scopes === undefined ? { service: c.service } : { service: c.service, scopes: structuredClone(c.scopes) })),
};
if (role.messaging) out.messaging = { sendTo: [...role.messaging.sendTo], receiveFrom: [...role.messaging.receiveFrom] };
if (role.skills.length) out.skills = [...role.skills];
return { rev: 1, from: { template: template.name, sha256: template.sha256 }, role: out, instructions: template.contract };
}
// ---- The diff read ----
// A line diff of two texts: [{ op: " " | "-" | "+", line }]. Longest common subsequence over lines;
// past MAX_CELLS it reports the old text removed and the new one added, which is still exact.
const MAX_CELLS = 4_000_000;
export function lineDiff(before, after) {
const a = before.split("\n");
const b = after.split("\n");
if (a.length * b.length > MAX_CELLS) {
return [...a.map((line) => ({ op: "-", line })), ...b.map((line) => ({ op: "+", line }))];
}
const width = b.length + 1;
const table = new Uint32Array((a.length + 1) * width);
for (let i = a.length - 1; i >= 0; i--) {
for (let j = b.length - 1; j >= 0; j--) {
table[i * width + j] = a[i] === b[j] ? table[(i + 1) * width + j + 1] + 1
: Math.max(table[(i + 1) * width + j], table[i * width + j + 1]);
}
}
const out = [];
let i = 0;
let j = 0;
while (i < a.length && j < b.length) {
if (a[i] === b[j]) {
out.push({ op: " ", line: a[i] });
i++;
j++;
} else if (table[(i + 1) * width + j] >= table[i * width + j + 1]) {
out.push({ op: "-", line: a[i++] });
} else {
out.push({ op: "+", line: b[j++] });
}
}
while (i < a.length) out.push({ op: "-", line: a[i++] });
while (j < b.length) out.push({ op: "+", line: b[j++] });
return out;
}
// For a copy whose from.sha256 differs from its template's current hash: which primitives differ
// between the copy as it stands and the template as it stands, and the instructions' line diff.
// The file keeps only the hash of what was imported, so a customized copy's own edits show here
// too. Nothing is applied: apply or keep is a change decision (A4).
export function diffDefinition(document, name, rolesDir) {
const definitions = document?.definitions ?? {};
if (!Object.hasOwn(definitions, name)) refuse(`definitions declares no ${name}`);
const entry = definitions[name];
const base = { definition: name, rev: entry.rev, from: entry.from ?? null };
if (!entry.from) return { ...base, current: null, changed: false, primitives: [], instructions: [] };
let template;
try {
template = readTemplate(rolesDir, entry.from.template);
} catch (error) {
if (error?.name !== "BusinessError") throw error;
return { ...base, current: null, changed: true, templateMissing: true, primitives: [], instructions: [] };
}
if (template.sha256 === entry.from.sha256) return { ...base, current: template.sha256, changed: false, primitives: [], instructions: [] };
const now = compositionOf(entryFromTemplate(template));
const copy = compositionOf(entry);
const primitives = PRIMITIVES.filter((key) => canonicalJson(copy[key] ?? null) !== canonicalJson(now[key] ?? null));
return {
...base, current: template.sha256, changed: true, primitives,
instructions: primitives.includes("instructions") ? lineDiff(copy.instructions, now.instructions) : [],
};
}
@@ -0,0 +1,232 @@
// A4: private, pinned proposals; public decision records contain metadata and hashes only.
// This is a trusted host service, never a worker import or a credential binding writer.
import { mkdirSync, writeFileSync, readFileSync, readdirSync, lstatSync, openSync, closeSync, fsyncSync } from 'node:fs';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import { buildRegistry, checkComposition } from './composition.mjs';
import { compositionOf, entryFromTemplate, readTemplate, templateHashes } from './definitions.mjs';
import { readRootAgentsFile, changeRootAgent } from './agent-levels-write.mjs';
import { validateBusinessDocument } from './organization-config.mjs';
import { canonicalJson, isPlainObject, readJsonFile } from './util.mjs';
import { ID_PATTERN, NETWORKS } from './vocabulary.mjs';
import { OrganizationError } from './organization.mjs';
import { resolveInstance } from './resolve.mjs';
import { checkRoutes } from './launch-settings.mjs';
import { VERB_RIGHTS } from './org-rights.mjs';
import { readRegistry, withRegistryWriter } from './org-registry.mjs';
const fail = code => { throw new OrganizationError(code); };
const hash = x => createHash('sha256').update(canonicalJson(x)).digest('hex');
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
const keys = (x, allowed, required = []) => { if (!isPlainObject(x) || Object.keys(x).some(k => !allowed.includes(k)) || required.some(k => !Object.hasOwn(x,k))) fail('invalid-request'); };
const id = x => { if (typeof x !== 'string' || !ID_PATTERN.test(x) || ['human','tester','user'].includes(x)) fail('invalid-request'); };
const requestKeys = ['op','level','project','name','role','credentials','personality','route','reason'];
export function authorizeHire(proposal, caller) {
if (!caller || caller.agent !== false) fail('human-required');
if (!caller.rights?.includes(VERB_RIGHTS['hire.resolve.console'])) fail('right-required');
if ((proposal.flagged.length || proposal.credentialLines.length) && caller.owner !== true) fail('hire-owner-required');
if (proposal.level === 'root' && caller.instanceOwner !== true) fail('hire-owner-required');
}
export function credentialLines(credentials) {
return credentials.flatMap(({service,scopes}) => scopes === undefined ? [{service}] : Array.isArray(scopes)
? scopes.map(scope => ({service,scope})) : Object.entries(scopes).flatMap(([group,verbs]) => verbs.map(verb => ({service,scope:`${group}.${verb}`}))));
}
const publicOf = p => ({ id:p.id, revision:p.revision, op:p.request.op, level:p.request.level, name:p.request.name,
...(p.request.project === undefined ? {} : {project:p.request.project}), route:p.request.route ?? null,
...(p.entry ? {role:structuredClone(p.entry.role)} : {}),
// reason and all free text stay in the private proposal as well.
flagged:p.flagged, credentialLines:p.credentialLines, expiresAt:p.expiresAt, digest:hash(p) });
export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertClean, system = {}, levels = () => null, catalogueFor = async () => null, registryFor = () => buildRegistry() }) {
const home = join(dataRoot,'authority-proposals');
let receiptCache;
const rootReceipts=()=>{
try{
const file=join(configDir,'agents-receipts.jsonl'),st=lstatSync(file,{bigint:true});
if(!st.isFile() || st.uid!==BigInt(process.getuid()) || (st.mode&0o022n) || st.nlink!==1n)throw new Error('unsafe receipts');
const stamp=[st.dev,st.ino,st.size,st.mtimeNs,st.ctimeNs].join(':');
if(receiptCache?.stamp===stamp)return receiptCache.rows;
const rows=readFileSync(file,'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
receiptCache={stamp,rows};return rows;
}
catch(error){if(error.code==='ENOENT')return [];throw error;}
};
let bootReceipts;
try{bootReceipts=rootReceipts();}catch{bootReceipts=null;}
const rootRefusal=name=>{
try {
if(!bootReceipts)return 'root-policy-unreadable';
const latest=rootReceipts().filter(r=>r.agent===name).at(-1),boot=bootReceipts.filter(r=>r.agent===name).at(-1);
if(latest?.verb==='root.agent.remove')return 'instance-fired';
return canonicalJson(latest??null)===canonicalJson(boot??null)?null:'copy-stale';
} catch {return 'root-policy-unreadable';}
};
async function assertRootFireLocal(business,registry=undefined) {
if(registry===undefined)registry=await readRegistry({configDir});
// Another organization can retain a boot snapshot and live external grants even after
// excludeOrgs changes. Without a cross-bus revoke protocol, refuse conservatively.
const others=new Set(Object.keys(registry?.orgs??{}));
for(const file of readdirSync(join(configDir,'businesses')))if(file.endsWith('.json') && ID_PATTERN.test(file.slice(0,-5)))others.add(file.slice(0,-5));
others.delete(business);
if(others.size)throw new OrganizationError('root-fire-shared',`root fire requires coordinated revocation in ${[...others].sort().join(', ')}`);
}
const location = value => { if (!UUID.test(value)) fail('invalid-request'); return join(home,`${value}.json`); };
const read = value => { try { return readJsonFile(location(value),'private hire proposal',st=>{if(st.uid!==process.getuid() || st.mode&0o077 || st.nlink!==1)fail('decision-stale');}); } catch { fail('decision-stale'); } };
async function prepare(business, input, { id: decision, expiresAt }) {
keys(input,requestKeys,['op','level','name','reason']); id(input.name);
if (!['hire','fire','change'].includes(input.op) || !['org','project','root'].includes(input.level)) fail('invalid-request');
if (typeof input.reason!=='string' || input.reason.trim().length===0 || input.reason.length>500) fail('invalid-request');
if (typeof assertClean!=='function') fail('definition-secret-check-required');
assertClean(input);
if (input.level==='project') id(input.project); else if ('project' in input) fail('invalid-request');
if (input.op!=='fire') {
keys(input.route,['harness','model','thinking'],['harness','model']);
for (const x of Object.values(input.route)) if (typeof x!=='string' || !x || x.length>200) fail('invalid-request');
keys(input.role,['template','sha256','definition','rev','inline']);
const count=['template','definition','inline'].filter(k=>k in input.role).length;
if (count!==1) fail('invalid-request');
if ('template' in input.role) { id(input.role.template); if (!/^[a-f0-9]{64}$/.test(input.role.sha256??'') || Object.keys(input.role).length!==2) fail('invalid-request'); }
if ('definition' in input.role) { id(input.role.definition); if (!Number.isSafeInteger(input.role.rev) || Object.keys(input.role).length!==2) fail('invalid-request'); }
if ('inline' in input.role && Object.keys(input.role).length!==1) fail('invalid-request');
} else if (['role','route','credentials','personality'].some(k=>k in input)) fail('invalid-request');
const {document:doc,revision} = writer.read(business);
const loaded = validateBusinessDocument(doc,`${business}.json`,{rolesDir,dataRoot});
let chief=loaded.definitions[doc.roles[doc.launch?.by]?.definition];
if(chief) {const resolved=resolveInstance({system,business:loaded,instance:doc.launch.by});chief={...chief,...resolved.limits};}
if (!chief) fail('no-chief-of-staff');
const root = input.level==='root' ? await readRootAgentsFile(configDir) : null;
if(input.level==='root' && input.op==='fire')await assertRootFireLocal(business);
if(root?.document.retired?.includes(input.name))fail('organization-instance-retired');
const entries = input.level==='root' ? root.document.agents : input.level==='org' ? doc.roles : doc.projects[input.project]?.agents ?? {};
if (input.level==='project' && (!doc.projects[input.project] || doc.projects[input.project].kind==='library')) fail('project-agents-invalid');
if ((doc.retired??[]).includes(input.name)) fail('organization-instance-retired');
if ((doc.launch?.keep??[]).includes(input.name)) fail('instance-kept');
if (input.op==='hire' && (doc.launch?.noHire??[]).includes(input.role?.template??input.role?.definition))fail('definition-not-hireable');
if ((input.op==='hire') === Object.hasOwn(entries,input.name)) fail(input.op==='hire'?'organization-instance-exists':'organization-unknown-instance');
if (input.name===doc.launch?.by || Object.values(doc.arbiters).includes(input.name)) fail('organization-instance-referenced');
let entry=null, flagged=[], lines=[];
if (input.op!=='fire') {
const ref=input.role;
if ('template' in ref) { const template=readTemplate(rolesDir,ref.template); if(template.sha256!==ref.sha256) fail('decision-stale'); entry=entryFromTemplate(template); }
else if ('definition' in ref) { entry=doc.definitions?.[ref.definition]; if(!entry || entry.rev!==ref.rev) fail('definition-rev-conflict'); entry=structuredClone(entry); }
else entry={rev:1,role:{},instructions:''};
let c='inline' in ref ? structuredClone(ref.inline) : compositionOf(entry);
if ('credentials' in input) c.credentials=structuredClone(input.credentials);
if ('personality' in input) c.personality=structuredClone(input.personality);
const checked=checkComposition(c,registryFor(doc),chief,{assertClean});
const {instructions,personality,...role}=checked.composition;
if(role.messaging===null)delete role.messaging;
entry={...entry,role,instructions,...(personality===null?{}:{personality})};
if(personality===null)delete entry.personality;
flagged=checked.flagged; lines=credentialLines(checked.composition.credentials);
// Validate the proposed route through the existing host route policy before a proposal
// reaches approval. Use a temporary org instance for project/root route validation too.
const routeDoc=structuredClone(doc), definition=`hire-${decision}`;
routeDoc.definitions={...(routeDoc.definitions??{}),[definition]:entry};
routeDoc.roles[input.name]={definition:`org:${definition}`,vars:input.route,hireDecision:decision};
const proposed=validateBusinessDocument(routeDoc,`${business}.json`,{rolesDir,dataRoot});
checkRoutes({proposed,old:loaded,levels,catalogue:await catalogueFor(business,doc)});
}
const current=entries[input.name];
if(input.op==='fire') {
const definition=current?.definition;
const value=definition?.startsWith('org:') ? (input.level==='root'?root.document.definitions:doc.definitions)?.[definition.slice(4)] : entryFromTemplate(readTemplate(rolesDir,definition));
if(!value)fail('definition-missing');
const checked=checkComposition(compositionOf(value),registryFor(doc),chief,{assertClean});
flagged=checked.flagged;lines=credentialLines(checked.composition.credentials);
}
if(input.op==='change' && current?.definition?.startsWith('org:') && input.level!=='root') {
const users=Object.entries(doc.roles).filter(([,e])=>e.definition===current.definition).map(([n])=>`org:${n}`);
for(const [project,value] of Object.entries(doc.projects))for(const [n,e]of Object.entries(value.agents??{}))if(e.definition===current.definition)users.push(`project:${project}:${n}`);
const target=input.level==='org'?`org:${input.name}`:`project:${input.project}:${input.name}`;
if(users.some(n=>n!==target))fail('definition-shared');
}
if(input.op==='change' && input.level==='root' && Object.entries(root.document.agents).some(([name,e])=>name!==input.name && e.definition===current.definition))fail('definition-shared');
const targetDefinition=input.op==='change' && current?.definition?.startsWith('org:') ? current.definition.slice(4) : `hire-${decision}`;
const p={id:decision,business,revision,rootRevision:root?.revision??null,request:structuredClone(input),entry,definition:targetDefinition,flagged,credentialLines:lines,expiresAt};
mkdirSync(home,{recursive:true,mode:0o700});
const st=lstatSync(home); if(!st.isDirectory() || st.isSymbolicLink() || st.uid!==process.getuid() || st.mode&0o077)fail('organization-refused');
const fd=openSync(location(decision),'wx',0o600);
try { writeFileSync(fd,JSON.stringify(p)); fsyncSync(fd); } finally { closeSync(fd); }
return publicOf(p);
}
async function adjusted(business, decision, adjust) {
const p=read(decision);
if(p.business!==business)fail('decision-stale');
if(adjust===undefined)return p;
keys(adjust,['role','credentials','route']);
if(!p.entry)fail('invalid-request');
assertClean(adjust);
const before=compositionOf(p.entry), after=structuredClone(before);
if(adjust.role!==undefined) {
keys(adjust.role,['tools','network','authority','messaging','skills']);
Object.assign(after,structuredClone(adjust.role));
}
if(adjust.credentials!==undefined)after.credentials=structuredClone(adjust.credentials);
const doc=writer.read(business).document;
const loaded=validateBusinessDocument(doc,`${business}.json`,{rolesDir,dataRoot});
const chief=loaded.definitions[doc.roles[doc.launch.by].definition];
const resolved=resolveInstance({system,business:loaded,instance:doc.launch.by});
const checked=checkComposition(after,registryFor(doc),{...chief,...resolved.limits},{assertClean});
const c=checked.composition, subset=(a,b)=>a.every(x=>b.includes(x));
if(!subset(c.tools,before.tools) || !subset(c.skills,before.skills??[]) || NETWORKS.indexOf(c.network)>NETWORKS.indexOf(before.network))fail('invalid-request');
// Treat the two authority domains literally. Moving an action to the other domain widens it.
for(const domain of ['withinRole','crossRole'])if(!subset(c.authority[domain],before.authority[domain]))fail('invalid-request');
if(before.messaging)for(const direction of ['sendTo','receiveFrom'])if(!c.messaging || !subset(c.messaging[direction],before.messaging[direction]))fail('invalid-request');
if(!subset(credentialLines(c.credentials).map(canonicalJson),credentialLines(before.credentials).map(canonicalJson)))fail('invalid-request');
const {instructions,personality,...role}=c;
if(role.messaging===null)delete role.messaging;
p.entry={...p.entry,role,instructions,...(personality===null?{}:{personality})};
p.flagged=checked.flagged;p.credentialLines=credentialLines(c.credentials);
if(adjust.route!==undefined) {
keys(adjust.route,['harness','model','thinking'],['harness','model']);
if(Object.values(adjust.route).some(x=>typeof x!=='string'||!x||x.length>200))fail('invalid-request');
p.request.route=structuredClone(adjust.route);
}
const routeDoc=structuredClone(doc), definition=`hire-${decision}`;
routeDoc.definitions={...(routeDoc.definitions??{}),[definition]:p.entry};
routeDoc.roles[p.request.name]={definition:`org:${definition}`,vars:p.request.route,hireDecision:decision};
checkRoutes({proposed:validateBusinessDocument(routeDoc,`${business}.json`,{rolesDir,dataRoot}),old:loaded,levels,catalogue:await catalogueFor(business,doc)});
// Bind the adjustment itself as well as its result. A confirmation cannot be reused for
// another edit, even when two edits happen to result in the same primitive set.
p.adjust=structuredClone(adjust);
return p;
}
async function check(business, decision, caller) {
const p=await adjusted(business,decision,caller.adjust), view=publicOf(p);
if (p.business!==business || caller.digest!==view.digest)fail('decision-stale');
if(Date.parse(p.expiresAt)<=(caller.now??Date.now)())fail('decision-expired');
authorizeHire(view,caller);
if(writer.read(business).revision!==p.revision)fail('decision-stale');
// Re-run composition against current policy and scan private text again before any effect.
if(p.entry) {
const doc=writer.read(business).document;
const loaded=validateBusinessDocument(doc,`${business}.json`,{rolesDir,dataRoot});
const chief=loaded.definitions[doc.roles[doc.launch.by].definition];
const resolved=resolveInstance({system,business:loaded,instance:doc.launch.by});
const checked=checkComposition(compositionOf(p.entry),registryFor(doc),{...chief,...resolved.limits},{assertClean});
if(canonicalJson(checked.flagged)!==canonicalJson(p.flagged))fail('decision-stale');
if(p.request.role.template && readTemplate(rolesDir,p.request.role.template).sha256!==p.request.role.sha256)fail('decision-stale');
}
if(p.rootRevision!==null && (await readRootAgentsFile(configDir)).revision!==p.rootRevision)fail('decision-stale');
if(p.request.level==='root' && p.request.op==='fire')await assertRootFireLocal(business);
return p;
}
async function apply(business, decision, caller) {
const p=await check(business,decision,caller);
const r=p.request;
const vars=r.route ? {harness:r.route.harness,model:r.route.model,...(r.route.thinking?{thinking:r.route.thinking}:{})} : {};
const currentDoc=r.level==='root'?(await readRootAgentsFile(configDir)).document:writer.read(business).document;
const approvedEntry=p.entry?{...p.entry,rev:(currentDoc.definitions?.[p.definition]?.rev??0)+1}:null;
const excluded=r.level==='root' && r.op==='change' ? currentDoc.agents[r.name]?.excludeOrgs : undefined;
const entry=r.op==='fire'?null:{definition:`org:${p.definition}`,vars,hireDecision:p.id,...(excluded===undefined?{}:{excludeOrgs:excluded})};
if(r.level==='root') {
const publish=()=>changeRootAgent(configDir,{revision:p.rootRevision,name:r.name,entry},
{actor:caller.subject,owner:caller.subject,rolesDir,approvedDefinition:p.entry?{name:p.definition,entry:approvedEntry}:null,decision:p.id,assertClean,beforeReplace:caller.beforeWrite});
if(r.op==='fire')return withRegistryWriter(configDir,async registry=>{await assertRootFireLocal(business,registry);return publish();});
return publish();
}
if(r.level==='project') return writer.setProjectAgent(business,{revision:p.revision,project:r.project,name:r.name,entry},caller.actor,
{approvedDefinition:p.entry?{name:p.definition,entry:approvedEntry}:null,decision:p.id,assertClean,beforeWrite:caller.beforeWrite});
return writer.applyHireDecision(business,p,{actor:caller.actor,assertClean});
}
return {prepare,templates:()=>({templates:templateHashes(rolesDir).map(({template,sha256})=>({name:template,sha256}))}),preview:async(business,decision,caller)=>{const p=await adjusted(business,decision,caller.adjust);if(Date.parse(p.expiresAt)<=(caller.now??Date.now)())fail('decision-expired');authorizeHire(publicOf(p),caller);return publicOf(p);},rootRefusal,rootActive:name=>rootRefusal(name)===null,check:async(...args)=>{await check(...args);},apply,read: decision => publicOf(read(decision))};
}
+3 -1
View File
@@ -77,7 +77,9 @@ export function checkCommands(commands, where) {
if (/[*?[]/.test(element)) refuse(`${at} must not hold *, ? or [; the match is literal`);
}
if (prefix[0].includes("/") && !prefix[0].startsWith("/")) refuse(`${at}[0] must be a bare name or an absolute path`);
if (refusedCommand(prefix[0])) refuse(`${at} starts with ${prefix[0]}, a shell, runner or interpreter, which would grant any command`);
// Name the command by its basename, which refusedCommand matched against a closed list or
// pattern, never by an absolute host path (PR #1916).
if (refusedCommand(prefix[0])) refuse(`${at} starts with ${prefix[0].split("/").pop()}, a shell, runner or interpreter, which would grant any command`);
const key = JSON.stringify(prefix);
if (seen.has(key)) refuse(`${at} repeats a prefix`);
seen.add(key);
+2 -2
View File
@@ -6,9 +6,9 @@
export { OrganizationConfigError, BusinessError } from "./errors.mjs";
export {
ACTIONS, GATED_ONLY, HIRE_ALLOWED, TOOLS, NETWORKS, SERVICES, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE,
ACTIONS, GATED_ONLY, TOOLS, NETWORKS, SERVICES, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE,
} from "./vocabulary.mjs";
export { validateRoleDocument, loadRoleFile, loadRole, unhireableAction } from "./role.mjs";
export { validateRoleDocument, loadRoleFile, loadRole } from "./role.mjs";
export { LAYERS, REGISTRY, THINKING, FALLBACK_VARS, checkVars, mergeVars } from "./vars.mjs";
export { parseCredentialRef, checkCredentialRef } from "./credentials.mjs";
export {
@@ -22,10 +22,6 @@ const VETO_OPS = ["keep", "unkeep", "nohire", "hire"];
const SCOPE_KEYS = ["revision", "scope"];
const id = value => typeof value === "string" && ID_PATTERN.test(value);
const revisionOk = value => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
// Row 194 (D2.2, N3): the vars a hire may set, the ones the chief can already set on an instance.
// Every limits.* key merges by intersection, so it can only narrow.
const HIRE_VAR = key => key === "model" || key.startsWith("limits.");
// Row 256 (#1761, REQ-COS-3): the END_PROTECTED part of a delete, for one actor. A creative
// instance is the human's to delete, and any other actor is refused, as row 213 refuses it a
// change. The assistant is refused for every actor, the human included. The listing's
@@ -150,7 +146,7 @@ function createdCredentials(old, instance, definition, args) {
// services) revokes the instance's credentials for a fire or a delete and throws
// credential-revoke-failed when it can't. Null in a writer with no bus (the CLI's), which revokes
// nothing. Row 239 (#1745): writer is the organization writer, injected only by tests.
export function instanceSettings({ dir, rolesDir, system, levels = () => null, revoke = null, writer = organizationWriter({ dir, rolesDir }) }) {
export function instanceSettings({ dir, rolesDir, system, levels = () => null, revoke = null, writer = organizationWriter({ dir, rolesDir, dataRoot:system?.dataRoot ?? null }) }) {
// The definitions in rolesDir and whether add and set accept each one.
function definitions() {
@@ -347,56 +343,13 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
return { ...commit(business, old, next, { revision: args.revision, actor }), scope, old: before };
}
// Row 194 (#1692, D2.2): the chief's hire, after the broker's identity, grant, allowlist and
// hireable checks. args { name, definition, vars? }. A new name is added and listed. A fired name
// (declared, not listed) is listed again only with its own definition and no vars. The writer
// reads the file as it stands, so a veto or retire written since the broker's copy still counts.
function prepareHire(business, args) {
const { document: old, revision } = writer.read(business);
if (!old.launch) throw new OrganizationError("organization-launch-not-configured");
const { name, definition } = args;
// FED R3 r4 (sage-2557): and user, the granted user's principal, which the broker's load check refuses.
if (!id(name) || !id(definition) || name === "human" || name === "tester" || name === "user") throw new OrganizationError("invalid-request");
if (Object.hasOwn(args, "vars") && (!isPlainObject(args.vars) || !Object.keys(args.vars).every(HIRE_VAR))) {
throw new OrganizationError("invalid-request", "hire vars may set only model and limits keys");
}
if ((old.launch.noHire ?? []).includes(definition)) throw new OrganizationError("definition-not-hireable");
// Row 213 (M10): definitionFor lets a creative definition through for the human's add and
// change. A hire is the chief's, so it refuses one here.
if (CREATIVE_DEFINITIONS.includes(definition)) {
throw new OrganizationError("definition-not-hireable", `${definition} is a creative definition, which only the human adds`);
}
if ((old.retired ?? []).includes(name)) throw new OrganizationError("organization-instance-retired");
if ((old.launch.keep ?? []).includes(name)) throw new OrganizationError("instance-kept");
const next = structuredClone(old);
if (Object.hasOwn(old.roles, name)) {
const fired = !old.launch.instances.includes(name) && old.launch.by !== name;
if (!fired || old.roles[name].definition !== definition || Object.hasOwn(args, "vars")) {
throw new OrganizationError("organization-instance-exists");
}
} else {
next.roles[name] = { definition, ...(Object.hasOwn(args, "vars") ? { vars: structuredClone(args.vars) } : {}) };
}
// mos-1585: duplicates refuse before definition eligibility, with no file or credential effect.
definitionFor(rolesDir, definition);
next.launch.instances.push(name);
return { old, next, revision };
}
// Trusted read-only lifecycle preparation; the writer uses the same checks again.
function checkHire(business, args) {
return { revision: prepareHire(business, args).revision };
}
function hire(business, args, { actor, context }) {
const { old, next, revision } = prepareHire(business, args);
return commit(business, old, next, { revision, actor, context });
}
// Row 194 (D2.2 step B): the fire's delist. An instance already delisted is done, so a repeated
// fire after a failed step C goes on to consume (webby-0011 B2). The checks repeat against the file.
function fire(business, args, { actor, context }) {
const { document: old, revision } = writer.read(business);
const { name } = args;
fireChecks(old, name);
if(args.revision!==undefined && args.revision!==revision)throw new OrganizationError("revision-conflict");
// Item 8: fire revokes each service of the entry and keeps the store directory (sage-1593).
const services = Object.keys(old.roles[name].credentials ?? {});
// webby-0094 N1: an instance already delisted (a hand edit, or a repeat after step B) still has
@@ -473,5 +426,5 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
return { ...read(business, actor), transaction: receipt.transaction, restart: false };
}
return { writer, read, set, checkDelete, checkFire, checkHire, veto, hire, fire, grantScope, grantScopeRead };
return { writer, read, set, checkDelete, checkFire, veto, fire, grantScope, grantScopeRead };
}
@@ -168,18 +168,18 @@ const ROUTE_FIELDS = Object.freeze(["harness", "model", "thinking"]);
// Without it, a write that adds a daily key quota refuses proxy-key-refused; no other write asks.
export function launchSettings({ dir, rolesDir, system, sessions = () => [], now = () => new Date().toISOString(), levels = () => null,
keyLineage = () => { throw new OrganizationError("proxy-key-refused"); } }) {
const writer = organizationWriter({ dir, rolesDir });
const writer = organizationWriter({ dir, rolesDir, dataRoot:system?.dataRoot ?? null });
// The validated business as the file stands now (row 125: the limit checks read it at each ask).
function current(id) {
return validateBusinessDocument(writer.read(id).document, businessFilePath(id, dir), { rolesDir });
return validateBusinessDocument(writer.read(id).document, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null });
}
// The file before a write, validated, for checkRoutes; null when it no longer validates, so every
// route is checked.
function before(id, document) {
try {
return validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir });
return validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null });
} catch (e) {
if (e.name === "BusinessError") return null;
throw e;
@@ -188,7 +188,7 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now
function view(id) {
const { document, revision } = writer.read(id);
const business = validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir });
const business = validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null });
if (!business.launch) throw new OrganizationError("launch-not-configured");
const instances = Object.fromEntries(Object.keys(business.roles).map(instance => {
const vars = resolveInstance({ business, system, instance }).vars;
@@ -305,7 +305,7 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now
if (next.roles[instance]?.definition !== entry.definition) throw new OrganizationError("organization-definition-refused");
}
// Check every instance's route before publishing the complete document.
const proposed = validateBusinessDocument(next, businessFilePath(id, dir), { rolesDir });
const proposed = validateBusinessDocument(next, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null });
checkRoutes({ proposed, old: before(id, old.document), levels, catalogue });
const receipt = writer.write(id, next, { revision: args.revision, actor });
return { ...view(id), transaction: receipt.transaction };
@@ -317,7 +317,7 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now
// doesn't set reads null; its launch refuses no-route.
function routes(id) {
const { document, revision } = writer.read(id);
const business = validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir });
const business = validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null });
if (!business.launch) throw new OrganizationError("launch-not-configured");
const harnesses = Array.isArray(business.plugins?.harness) ? [...business.plugins.harness] : [];
const named = new Set(harnesses);
@@ -397,7 +397,7 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now
next.roles[instance].vars = vars;
}
if (canonicalJson(next) === canonicalJson(old.document)) throw new OrganizationError("organization-no-change");
const proposed = validateBusinessDocument(next, businessFilePath(id, dir), { rolesDir });
const proposed = validateBusinessDocument(next, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null });
checkRoutes({ proposed, old: before(id, old.document), levels, catalogue });
const receipt = writer.write(id, next, { revision: args.revision, actor });
return { ...routes(id), transaction: receipt.transaction };
+4 -2
View File
@@ -24,18 +24,20 @@ export function defaultRolesDir(env = process.env) {
export function loadSeatSets({ dir = configDir(), rolesDir = defaultRolesDir() } = {}) {
// lstat first: only nothing at the path is an absent directory. Anything else
// there (a file, a dangling symlink, a directory) must list, or it throws.
// The refusal names the directory by label and the error by its code only:
// err.message quotes the host path (PR #1916).
const businesses = join(dir, "businesses");
try {
lstatSync(businesses);
} catch (err) {
if (err.code === "ENOENT") return { librarySeats: new Set(), creativeSeats: new Set() };
throw new BusinessError(`cannot list businesses in ${dir}: ${err.message}`);
throw new BusinessError(`cannot list businesses in the config directory: ${err.code ?? "unreadable"}`);
}
let names;
try {
names = readdirSync(businesses).filter((name) => name.endsWith(".json"));
} catch (err) {
throw new BusinessError(`cannot list businesses in ${dir}: ${err.message}`);
throw new BusinessError(`cannot list businesses in the config directory: ${err.code ?? "unreadable"}`);
}
const librarySeats = new Set();
const creativeSeats = new Set();
@@ -228,3 +228,6 @@ export async function orgLock(ctx, id, { clear = false } = {}) {
}
}
}
// PL1: the organization primitive set, derived from enabled, verified manifests only.
export { organizationPlugins as buildOrganizationRegistry } from "../../plugins/src/organization.mjs";
+8
View File
@@ -67,6 +67,14 @@ export const VERB_RIGHTS = record({
'decision.preview.console': 'decide',
'decision.resolve.console': 'decide',
'hire.resolve.console': 'org.hire',
'hire.preview.console': 'org.hire',
'hire.start.console': 'org.hire',
'hire.templates.console': 'org.hire',
// #1873 T1: the human verbs on an organization's own definitions.
'definition.import.console': 'org.hire',
'definition.edit.console': 'org.hire',
'definition.invent.console': 'org.hire',
'definition.remove.console': 'org.hire',
'request.answer.console': 'request.write',
'request.attach.console': 'request.write',
'request.cancel.console': 'request.write',
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff. Show more