Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7c9eee603 | ||
|
|
1d07df0d61 | ||
|
|
be698e602b | ||
|
|
d085b16136 | ||
|
|
4c8bf9ca4e | ||
|
|
e588fb32cb | ||
|
|
e3577e9d31 | ||
|
|
99ce579e3c | ||
|
|
b44b14c7df | ||
|
|
85858938fc | ||
|
|
6415e81ee7 | ||
|
|
15225c5e1c | ||
|
|
cb9580f6fb | ||
|
|
c0e08f81ea |
No files matched your search
@@ -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.
|
||||
@@ -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, unhireableAction, 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([
|
||||
@@ -193,6 +193,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']);
|
||||
@@ -533,7 +542,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 +602,7 @@ export class Broker {
|
||||
#builtin;
|
||||
#builtinBinding;
|
||||
#organization;
|
||||
#pluginService;
|
||||
#credentialVerbs;
|
||||
#proxyInit;
|
||||
#proxyPage;
|
||||
@@ -655,7 +665,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, 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 +688,7 @@ export class Broker {
|
||||
this.#secretCheck = secretCheck;
|
||||
this.#credentialStatus = credentialStatus;
|
||||
this.#organization = organization;
|
||||
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;
|
||||
@@ -2151,6 +2162,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');
|
||||
@@ -4668,7 +4686,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 +5940,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') {
|
||||
@@ -6057,7 +6091,7 @@ export class Broker {
|
||||
}
|
||||
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 +6146,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
|
||||
@@ -6388,6 +6429,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.
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,8 @@ const group = (codes, rail, nextStep) => {
|
||||
entries.push([code, guidance]);
|
||||
}
|
||||
};
|
||||
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",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
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 +35,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';
|
||||
@@ -71,10 +75,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 +94,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 +102,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 +299,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 +414,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 +487,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 +506,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 +599,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 +700,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 +769,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 +800,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 ?? []}};
|
||||
@@ -832,13 +841,19 @@ 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});
|
||||
@@ -868,7 +883,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 +956,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 +978,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 +1231,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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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, unhireableAction, 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/);
|
||||
|
||||
@@ -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";
|
||||
@@ -0,0 +1,2 @@
|
||||
// Old path of packages/organization/src/definitions.mjs (rename design r2, stage B).
|
||||
export * from "../../organization/src/definitions.mjs";
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);}
|
||||
}
|
||||
@@ -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: {} };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -378,6 +378,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 +405,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 +464,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');
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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';",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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) } : {}) });
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -188,11 +188,11 @@ 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 }) {
|
||||
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 +234,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
|
||||
|
||||
@@ -86,9 +86,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 }) }); }
|
||||
|
||||
@@ -34,7 +34,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
|
||||
@@ -137,7 +137,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 +169,7 @@ export function buildRegistry({ plugins = [] } = {}) {
|
||||
routed: [...ROUTED, ...pluginActions],
|
||||
services,
|
||||
plugins: declared,
|
||||
roleTemplates: declared.flatMap(p=>p.roleTemplates),
|
||||
});
|
||||
BUILT.add(registry);
|
||||
return registry;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
// 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 hires nobody: hireable stays empty.
|
||||
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,
|
||||
hireable: [], 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) {
|
||||
return Object.entries(document?.roles ?? {})
|
||||
.filter(([, entry]) => entry?.definition === ORG_PREFIX + name).map(([instance]) => instance).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, { hireable: false });
|
||||
if (role.roleVersion !== 2) refuse(`template ${name} is role version 1 and has no contract`);
|
||||
const contract = readFileSync(role.contractPath);
|
||||
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) : [],
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -150,7 +150,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() {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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";
|
||||
@@ -67,6 +67,11 @@ export const VERB_RIGHTS = record({
|
||||
'decision.preview.console': 'decide',
|
||||
'decision.resolve.console': 'decide',
|
||||
'hire.resolve.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',
|
||||
|
||||
@@ -19,15 +19,22 @@ const encode = doc => JSON.stringify(doc, null, 2) + '\n';
|
||||
const projectPath = (root, project) => join(root, 'projects', project);
|
||||
function authorize(ctx) { if (!ctx || typeof ctx.instance !== 'string' || !/^[a-z][a-z0-9]{1,15}$/.test(ctx.instance) || ctx.instance === 'dev' || !same(ctx.principal, owner)) fail('org-forbidden', 'authenticated local development owner required'); }
|
||||
function checkId(id) { if (typeof id !== 'string' || !ID_PATTERN.test(id)) fail('org-invalid', 'invalid id'); }
|
||||
async function privateRead(file) { const fd = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); try { const st = await fd.stat(); if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe file', file); return await fd.readFile('utf8'); } finally { await fd.close(); } }
|
||||
// A filesystem error names the operation, the file by label and the error code, never the
|
||||
// host path Node puts in its message (PR #1916). code, syscall and the other fields stay.
|
||||
const quieted = new WeakSet();
|
||||
function quiet(e, what) {
|
||||
if (e && typeof e === 'object' && typeof e.syscall === 'string' && typeof e.code === 'string' && !quieted.has(e)) { e.message = `cannot ${e.syscall} ${what} (${e.code})`; quieted.add(e); }
|
||||
return e;
|
||||
}
|
||||
async function privateRead(file, what = 'organization file') { let fd; try { fd = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); } catch (e) { throw quiet(e, what); } try { const st = await fd.stat(); if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe file', file); return await fd.readFile('utf8'); } catch (e) { throw quiet(e, what); } finally { await fd.close(); } }
|
||||
async function atomic(file, content, exclusive = false) {
|
||||
const tmp = join(dirname(file), `.org-write-${randomBytes(12).toString('hex')}`);
|
||||
try { await fs.writeFile(tmp, content, { mode: 0o600, flag: 'wx' }); if (exclusive) await fs.link(tmp, file); else await fs.rename(tmp, file); } finally { await fs.rm(tmp, { force: true }); }
|
||||
try { await fs.writeFile(tmp, content, { mode: 0o600, flag: 'wx' }); if (exclusive) await fs.link(tmp, file); else await fs.rename(tmp, file); } catch (e) { throw quiet(e, 'organization file'); } finally { await fs.rm(tmp, { force: true }); }
|
||||
}
|
||||
async function ensurePrivateDir(path) { try { await fs.mkdir(path, { mode: 0o700 }); } catch (e) { if (e.code !== 'EEXIST') throw e; } const st = await fs.lstat(path); if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe directory', path); }
|
||||
async function ensurePrivateDir(path) { try { await fs.mkdir(path, { mode: 0o700 }); } catch (e) { if (e.code !== 'EEXIST') throw quiet(e, 'organization directory'); } const st = await fs.lstat(path).catch(e => { throw quiet(e, 'organization directory'); }); if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe directory', path); }
|
||||
async function receipt(root, op, org, principal, fields = {}) {
|
||||
const result = { op, org, at: new Date().toISOString(), subject: { ...principal }, ...fields };
|
||||
const file = join(root, 'receipts.jsonl'); const fd = await fs.open(file, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
|
||||
const file = join(root, 'receipts.jsonl'); const fd = await fs.open(file, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600).catch(e => { throw quiet(e, 'organization receipts'); });
|
||||
try { const st = await fd.stat(); if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe receipts', file); await fd.writeFile(JSON.stringify(result) + '\n'); } finally { await fd.close(); } return result;
|
||||
}
|
||||
async function receipts(root) {
|
||||
@@ -129,11 +136,11 @@ async function readCurrentUnlocked(ctx, id, { includeArchived = false } = {}) {
|
||||
if (found.state === 'archived' && !includeArchived) fail('org-archived', 'organization is archived');
|
||||
if (found.state !== 'active' && !(includeArchived && found.state === 'archived')) fail('org-unavailable', 'organization is not active');
|
||||
const ownerFile = join(found.root, 'owner.json');
|
||||
const principal = parseJsonFileText(await privateRead(ownerFile), ownerFile, 'organization owner file'); if (!same(principal, ctx.principal)) fail('org-forbidden', 'organization owner required');
|
||||
const principal = parseJsonFileText(await privateRead(ownerFile, 'organization owner file'), ownerFile, 'organization owner file'); if (!same(principal, ctx.principal)) fail('org-forbidden', 'organization owner required');
|
||||
// From here the caller is the verified owner. A failure carries that proof so a list may
|
||||
// show this org as broken to its owner; earlier failures prove nothing and stay hidden.
|
||||
try {
|
||||
const entry = (await readRegistry(ctx)).orgs[id]; const file = join(ctx.configDir, 'businesses', `${id}.json`); const raw = await privateRead(file);
|
||||
const entry = (await readRegistry(ctx)).orgs[id]; const file = join(ctx.configDir, 'businesses', `${id}.json`); const raw = await privateRead(file, 'organization file');
|
||||
if (!entry.digest || entry.digest !== digest(raw)) fail('org-mismatch', 'organization document and registry disagree');
|
||||
const doc = parseJsonFileText(raw, file, 'organization file'); await checked(doc); return { ...found, revision: entry.revision, doc, principal };
|
||||
} catch (e) { if (e && typeof e === 'object') e.ownerVerified = true; throw e; }
|
||||
@@ -171,13 +178,13 @@ export async function showOrg(ctx, id, opts = {}) { authorize(ctx); const { root
|
||||
export async function notificationShow(ctx, id) {
|
||||
authorize(ctx); const current = await readCurrent(ctx, id);
|
||||
for (const path of [join(current.root, 'notify'), join(current.root, 'notify', id)]) {
|
||||
const st = await fs.lstat(path);
|
||||
const st = await fs.lstat(path).catch(e => { throw quiet(e, 'notifier directory'); });
|
||||
if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe notifier directory');
|
||||
}
|
||||
const file = join(current.root, 'notify', id, 'notify.json');
|
||||
const st = await fs.lstat(file);
|
||||
const st = await fs.lstat(file).catch(e => { throw quiet(e, 'notifier configuration file'); });
|
||||
if (!st.isFile() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) fail('org-mismatch', 'unsafe notifier configuration');
|
||||
const binding = parseJsonFileText(await privateRead(file), file, 'notifier configuration file');
|
||||
const binding = parseJsonFileText(await privateRead(file, 'notifier configuration file'), file, 'notifier configuration file');
|
||||
if (!binding || Object.keys(binding).sort().join(',') !== 'binding,notifyVersion' || binding.notifyVersion !== 1 || !(binding.binding === null || typeof binding.binding === 'string' && BINDING_NAME.test(binding.binding))) fail('org-mismatch', 'invalid notifier configuration');
|
||||
const record = (await receipts(current.root)).find(r => r.op === 'org.create' && r.org === id && same(r.subject, current.principal));
|
||||
if (!record || record.notifications !== 'none') fail('org-mismatch', 'notification choice receipt unavailable');
|
||||
@@ -198,7 +205,7 @@ function options({ mode, remote, acceptLocalLossRisk }) {
|
||||
}
|
||||
async function git(root, args) {
|
||||
const env = { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: '/nonexistent', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C' };
|
||||
return new Promise((resolve, reject) => { const p = spawn('git', ['-c', 'user.name=Mosaic Local', '-c', '[email protected]', '-c', 'core.hooksPath=/dev/null', '-C', root, ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] }); let out = '', err = ''; p.stdout.on('data', d => out += d); p.stderr.on('data', d => err += d); p.on('error', reject); p.on('close', code => code === 0 ? resolve(out.trim()) : reject(new OrgError('org-git', `git failed: ${err.trim()}`))); });
|
||||
return new Promise((resolve, reject) => { const p = spawn('git', ['-c', 'user.name=Mosaic Local', '-c', '[email protected]', '-c', 'core.hooksPath=/dev/null', '-C', root, ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] }); let out = '', err = ''; p.stdout.on('data', d => out += d); p.stderr.on('data', d => err += d); p.on('error', reject); p.on('close', code => code === 0 ? resolve(out.trim()) : reject(new OrgError('org-git', `git ${args[0]} failed (exit ${code})`))); });
|
||||
}
|
||||
async function initGit(root) { await fs.mkdir(root, { mode: 0o700 }); await git(root, ['init', '--quiet', '--template=']); await git(root, ['commit', '--quiet', '--allow-empty', '-m', 'Genesis']); return git(root, ['rev-parse', 'HEAD']); }
|
||||
async function setup(ctx, id, project, opts) {
|
||||
@@ -273,6 +280,8 @@ export async function restoreOrg(ctx, id, opts = {}) { return lifecycleChange(ct
|
||||
|
||||
// Deletion keeps a durable marker in the registry and a receipt beside the
|
||||
// moved data. It never opens the contents of organization secret trees.
|
||||
// The deletion folder deleteOrg generates: `<id>-<ISO time with : and . as ->-<8 hex>`.
|
||||
const deletionFolderPattern = (id) => new RegExp(`^${id.replace(/[.]/g, '\\.')}-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}-\\d{3}Z-[0-9a-f]{8}$`);
|
||||
async function deleteInspect(path, { secret = false } = {}) {
|
||||
let count = 0;
|
||||
async function visit(p) {
|
||||
@@ -328,7 +337,7 @@ export async function deleteOrg(ctx, id, { confirm, ifRevision, busStop } = {})
|
||||
try {
|
||||
await deleteSafeRoot(ctx.dataRoot);
|
||||
await ensurePrivateDir(deleted); await ensurePrivateDir(destination);
|
||||
try { result = parseJsonFileText(await privateRead(receiptFile), receiptFile, 'deletion receipt file'); }
|
||||
try { result = parseJsonFileText(await privateRead(receiptFile, 'deletion receipt file'), receiptFile, 'deletion receipt file'); }
|
||||
catch (e) { if (e.code !== 'ENOENT') throw e; result = { op: 'org.delete', org: id, actor: deletion.actor, time: deletion.time, revision: deletion.revision, destination, bus: null, moves: [], secrets: [], note: 'Purging deleted data and remote-side revocation are out of scope.' }; await atomic(receiptFile, encode(result), true); }
|
||||
if (result.org !== id || result.destination !== destination || result.revision !== deletion.revision || !Array.isArray(result.moves) || !Array.isArray(result.secrets)) fail('org-mismatch', 'invalid deletion receipt');
|
||||
if (!result.bus) {
|
||||
@@ -380,9 +389,12 @@ export async function deleteOrg(ctx, id, { confirm, ifRevision, busStop } = {})
|
||||
});
|
||||
registryRemoved = true; result.registry = 'removed'; await atomic(receiptFile, encode(result)); return result;
|
||||
} catch (e) {
|
||||
e.deleteStep = phase; if (result) e.deleteReceipt = result;
|
||||
if (registryRemoved) { e.message += `; registry removal completed but the receipt could not be finalized at ${receiptFile}`; throw e; }
|
||||
e.message += `; deletion remains in progress. Inspect ${destination}/receipt.json and rerun mosaic org delete ${id} --confirm ${id}`;
|
||||
e.deleteStep = phase; if (result) e.deleteReceipt = result; quiet(e, `a file in the deletion ${phase} step`);
|
||||
// Name the receipt under the data root, never by host path (PR #1916). The folder comes
|
||||
// from the registry, so it is named only when it has the shape this function generates.
|
||||
const at = deletionFolderPattern(id).test(deletion.folder) ? `deleted/${deletion.folder}/receipt.json under the data root` : 'the deletion receipt under the data root';
|
||||
if (registryRemoved) { e.message += `; registry removal completed but the receipt could not be finalized at ${at}`; throw e; }
|
||||
e.message += `; deletion remains in progress. Inspect ${at} and rerun mosaic org delete ${id} --confirm ${id}`;
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -10,9 +10,11 @@ import { checkModels, checkLimits, checkAccount } from "./launch.mjs";
|
||||
import { checkVars } from "./vars.mjs";
|
||||
import { parseCredentialRef } from "./credentials.mjs";
|
||||
import { loadRole } from "./role.mjs";
|
||||
import { checkDefinitions, checkDefinitionName, orgDefinitionName, ORG_PREFIX } from "./definitions.mjs";
|
||||
import { END_PROTECTED } from "./vocabulary.mjs";
|
||||
import { checkPlugins, checkUsageSettings } from "../../plugins/src/kinds.mjs";
|
||||
import { hasModule } from "../../plugins/src/loader.mjs";
|
||||
import { readPluginLock } from "../../plugins/src/pinned.mjs";
|
||||
import { checkMapping } from "../../library/src/mapping.mjs";
|
||||
import { forkProblem, giteaName, parseGiteaUrl, HOST_SERVICE, CREATIVE_DEFINITIONS } from "./vocabulary.mjs";
|
||||
import { checkAppearance } from "./appearance.mjs";
|
||||
@@ -25,7 +27,7 @@ const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
|
||||
// How long a thread's session stays up with no turn before the launcher stops
|
||||
// it, when launch.threadIdleSeconds is absent (REQ-THREAD-2, row 121).
|
||||
export const THREAD_IDLE_SECONDS = 900;
|
||||
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired", "replyLimits", "usage", "appearance"];
|
||||
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired", "replyLimits", "usage", "appearance", "definitions"];
|
||||
|
||||
// The config directory: next to the system config, $MOSAIC_CONFIG or
|
||||
// ~/.config/mosaic-dev/config.json, the same rule mosaic-config.mjs uses.
|
||||
@@ -121,7 +123,7 @@ function checkPersonaVars(vars, definition, at) {
|
||||
// so the business file can't name them. The key stays known so the refusal says where they went.
|
||||
const HOSTS_MOVED = "lives in the bus store now: enroll a host with a host-enroll grant (mosaic hosts status)";
|
||||
|
||||
function checkRoles(roles, file, rolesDir, business, hasLibrary) {
|
||||
function checkRoles(roles, file, rolesDir, business, hasLibrary, orgDefinitions) {
|
||||
const where = `${file} roles`;
|
||||
requireObject(roles, where);
|
||||
if (Object.keys(roles).length === 0) refuse(`${where} must declare at least one role instance`);
|
||||
@@ -134,8 +136,14 @@ function checkRoles(roles, file, rolesDir, business, hasLibrary) {
|
||||
const at = `${where}.${instance}`;
|
||||
requireObject(entry, at);
|
||||
rejectUnknownKeys(entry, ["definition", "holder", "vars", "tracker", "credentials", "identity", "publish", "hosts"], at);
|
||||
requireId(entry.definition, `${at}.definition`);
|
||||
const definition = Object.hasOwn(definitions, entry.definition) ? definitions[entry.definition] : loadRole(rolesDir, entry.definition);
|
||||
// #1873 T1: "org:<name>" names one of this file's definitions; anything else is a template.
|
||||
const orgName = orgDefinitionName(entry.definition);
|
||||
if (orgName === null) requireId(entry.definition, `${at}.definition`);
|
||||
else if (!Object.hasOwn(orgDefinitions, ORG_PREFIX + checkDefinitionName(orgName, `${at}.definition after org:`))) {
|
||||
refuse(`definition-missing: ${at}.definition names ${ORG_PREFIX}${orgName}, which definitions doesn't declare`);
|
||||
}
|
||||
const definition = Object.hasOwn(definitions, entry.definition) ? definitions[entry.definition]
|
||||
: orgName === null ? loadRole(rolesDir, entry.definition) : orgDefinitions[entry.definition];
|
||||
definitions[entry.definition] = definition;
|
||||
if (definition.roleVersion !== 2) refuse(`${at}: role definition ${entry.definition} is version 1 and can't back a business role instance`);
|
||||
const services = definition.credentials.map((c) => c.service);
|
||||
@@ -238,7 +246,9 @@ function checkRetired(retired, roles, file) {
|
||||
|
||||
// Validate a parsed business document read from `file`. `rolesDir` is where
|
||||
// role definitions live. Returns a frozen business with its definitions.
|
||||
export function validateOrganizationDocument(document, file, { rolesDir }) {
|
||||
// options.assertClean is the credential store's secret check for the definitions' text, when the
|
||||
// caller holds one (#1873 T1); without it the check is the empty store's.
|
||||
export function validateOrganizationDocument(document, file, { rolesDir, dataRoot = null, assertClean } = {}) {
|
||||
requireObject(document, "business file");
|
||||
rejectUnknownKeys(document, TOP_KEYS, "business file");
|
||||
if (document.businessVersion !== 1) refuse(`business file "businessVersion" must be 1 (${file})`);
|
||||
@@ -252,7 +262,8 @@ export function validateOrganizationDocument(document, file, { rolesDir }) {
|
||||
const vars = checkVars(document.vars ?? {}, "business", `${file} vars`);
|
||||
if (document.hosts !== undefined) refuse(`${file} hosts ${HOSTS_MOVED}`);
|
||||
const hasLibrary = Object.values(document.projects ?? {}).some((project) => project?.kind === "library");
|
||||
const { roles, definitions } = checkRoles(document.roles, file, rolesDir, document.id, hasLibrary);
|
||||
const orgDefinitions = checkDefinitions(document.definitions, file, assertClean ? { assertClean } : {});
|
||||
const { roles, definitions } = checkRoles(document.roles, file, rolesDir, document.id, hasLibrary, orgDefinitions);
|
||||
|
||||
requireObject(document.arbiters, `${file} arbiters`);
|
||||
rejectUnknownKeys(document.arbiters, ["delivery", "technical"], `${file} arbiters`);
|
||||
@@ -333,8 +344,13 @@ export function validateOrganizationDocument(document, file, { rolesDir }) {
|
||||
// here; whether each module loads is the loader's check, at load time.
|
||||
const plugins = document.plugins === undefined ? {}
|
||||
: structuredClone(checkPlugins(document.plugins, `${file} plugins`, { fail: (_code, message) => refuse(message) }));
|
||||
let installedNames = new Set();
|
||||
if (dataRoot !== null && plugins["system-type"]?.some(id=>!hasModule("system-type",id))) {
|
||||
try { installedNames = new Set(Object.keys(readPluginLock(dataRoot).plugins)); }
|
||||
catch { installedNames = null; } // the loader reports an unreadable lock; core still loads
|
||||
}
|
||||
for (const id of plugins["system-type"] ?? []) {
|
||||
if (!hasModule("system-type", id)) refuse(`${file} plugins.system-type names unknown module ${id}`);
|
||||
if (!hasModule("system-type", id) && installedNames !== null && !installedNames.has(id)) refuse(`${file} plugins.system-type names unknown module ${id}`);
|
||||
}
|
||||
const usage = document.usage === undefined ? undefined : checkUsageSettings(document.usage);
|
||||
const appearance = checkAppearance(document.appearance, `${file} appearance`);
|
||||
@@ -349,14 +365,14 @@ export function validateOrganizationDocument(document, file, { rolesDir }) {
|
||||
// Load businesses/<id>.json from the config directory. The file holds
|
||||
// authority, so it must belong to this user and not be writable by group
|
||||
// or other.
|
||||
export function loadOrganization(id, { dir = configDir(), rolesDir }) {
|
||||
export function loadOrganization(id, { dir = configDir(), rolesDir, dataRoot = null }) {
|
||||
if (!rolesDir) throw new Error("loadBusiness needs rolesDir");
|
||||
const file = organizationFilePath(id, dir);
|
||||
const document = readJsonFile(file, "business file", (stat) => {
|
||||
const document = readJsonFile(file, `business file ${id}.json`, (stat) => {
|
||||
if (stat.uid !== process.getuid()) refuse(`business file must belong to uid ${process.getuid()}: ${file}`);
|
||||
if ((stat.mode & 0o022) !== 0) refuse(`business file must not be writable by group or other (mode ${(stat.mode & 0o777).toString(8)}): ${file}`);
|
||||
});
|
||||
return validateOrganizationDocument(document, file, { rolesDir });
|
||||
return validateOrganizationDocument(document, file, { rolesDir, dataRoot });
|
||||
}
|
||||
|
||||
// The old names (rename design r2, module map): the same functions, not copies.
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ADDITION_VERB, additionProblem } from "./project-bootstrap.mjs";
|
||||
import { remoteProblem, remoteTarget } from "./project-remotes.mjs";
|
||||
import { withRegistryWriter } from "./org-registry.mjs";
|
||||
import { entryForm, projectAgentProblem, rootForProjectCheck } from "./agent-levels-write.mjs";
|
||||
import { checkDefinition, checkDefinitionName, instancesNaming } from "./definitions.mjs";
|
||||
|
||||
export class OrganizationError extends Error {
|
||||
constructor(code, message = code) {
|
||||
@@ -140,7 +141,7 @@ export function setReplyLimits(writer, id, args, actor) {
|
||||
return writer.write(id, { ...document, replyLimits }, { revision: args.revision, actor });
|
||||
}
|
||||
|
||||
export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = () => {} }) {
|
||||
const evidence = join(dir, "businesses", ".history");
|
||||
const logFile = id => join(evidence, id, "changes.jsonl");
|
||||
// Keep the writer's established busy refusal when the shared registry lock
|
||||
@@ -155,7 +156,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
const file = businessFilePath(id, dir);
|
||||
const bytes = regular(file);
|
||||
const document = parseJsonFileText(bytes, file, "organization file");
|
||||
validateBusinessDocument(document, file, { rolesDir });
|
||||
validateBusinessDocument(document, file, { rolesDir, dataRoot });
|
||||
return { document, revision: sha256(bytes) };
|
||||
}
|
||||
|
||||
@@ -297,7 +298,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
modelQuotaFence(old, document, actor);
|
||||
usageFence(old, document, actor);
|
||||
systemTypesFence(old, document, actor);
|
||||
validateBusinessDocument(document, file, { rolesDir });
|
||||
validateBusinessDocument(document, file, { rolesDir, dataRoot });
|
||||
const bytes = JSON.stringify(document, null, 2) + "\n";
|
||||
// Check and log exactly what lands on disk, for every setter: a value JSON
|
||||
// can't carry (a Date, a Map) serializes as something else (row 131, sage-0314).
|
||||
@@ -307,7 +308,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
modelQuotaFence(old, written, actor);
|
||||
usageFence(old, written, actor);
|
||||
systemTypesFence(old, written, actor);
|
||||
validateBusinessDocument(written, file, { rolesDir });
|
||||
validateBusinessDocument(written, file, { rolesDir, dataRoot });
|
||||
if (beforeWrite) {
|
||||
beforeWrite();
|
||||
revoked = true;
|
||||
@@ -352,8 +353,62 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
const projectsProtected = (old, document) => {
|
||||
if (canonicalJson(document?.projects) !== canonicalJson(old.projects)) fail("organization-projects-protected");
|
||||
};
|
||||
// #1873 T1: definitions change only through setDefinition, which bumps rev and checks the entry.
|
||||
const definitionsProtected = (old, document) => {
|
||||
if (canonicalJson(document?.definitions ?? {}) !== canonicalJson(old.definitions ?? {})) fail("organization-definitions-protected");
|
||||
};
|
||||
function write(id, document, options) {
|
||||
return commit(id, document, { ...options, guard: projectsProtected });
|
||||
return commit(id, document, { ...options, guard: (old, next) => { projectsProtected(old, next); definitionsProtected(old, next); } });
|
||||
}
|
||||
|
||||
// #1873 T1: the only writer of `definitions`. `next(current)` returns the new entry without its
|
||||
// rev, or null to remove it; current is the entry or null. Nothing else in the document may change.
|
||||
// An agent or the tester never writes one. A write bumps rev (1 for a new name); `rev`, when given,
|
||||
// must be the current one (definition-rev-conflict). Editing or removing a definition an instance
|
||||
// names refuses definition-in-use: changing a hired agent's role is a change decision (A4).
|
||||
// assertClean is the credential store's held-secret check (the bus's credentials.assertClean). A
|
||||
// write that adds or changes an entry needs it: with no check to run, the write refuses (R1).
|
||||
function setDefinition(id, name, next, { revision, rev, actor, detail, assertClean }) {
|
||||
checkDefinitionName(name, "definition name");
|
||||
const logged = { ...detail, definition: name, rev: null };
|
||||
const guard = (old, document) => {
|
||||
const agents = Object.entries(old.roles ?? {}).flatMap(([n, role]) => [n, `${role?.definition}.${n}`]);
|
||||
if (actor === "tester" || agents.includes(actor) || (old.retired ?? []).includes(actor)) fail("human-required");
|
||||
const { definitions: a = {}, ...restOld } = old;
|
||||
const { definitions: b = {}, ...restNew } = document ?? {};
|
||||
if (canonicalJson(restNew) !== canonicalJson(restOld)) fail("organization-definitions-only");
|
||||
const others = (d) => canonicalJson(Object.fromEntries(Object.entries(d).filter(([k]) => k !== name)));
|
||||
if (others(a) !== others(b)) fail("organization-definitions-only");
|
||||
const current = Object.hasOwn(a, name) ? a[name] : null;
|
||||
const sansRev = (e) => canonicalJson({ ...e, rev: 0 });
|
||||
if (current && Object.hasOwn(b, name) && sansRev(current) === sansRev(b[name])) fail("definition-unchanged", `definition ${name} is unchanged`);
|
||||
if (current) {
|
||||
const users = instancesNaming(old, name);
|
||||
if (users.length) fail("definition-in-use", `definition ${name} is named by ${users.join(", ")}; a change to a hired agent's role is a change decision`);
|
||||
}
|
||||
if (Object.hasOwn(b, name)) {
|
||||
const want = current ? current.rev + 1 : 1;
|
||||
if (b[name]?.rev !== want) fail("definition-rev-conflict", `definition ${name} must be written at rev ${want}`);
|
||||
if (typeof assertClean !== "function") fail("definition-secret-check-required", "a definition write needs the credential store's secret check; write it through the bus");
|
||||
checkDefinition(name, b[name], { assertClean });
|
||||
}
|
||||
};
|
||||
const out = commit(id, (old) => {
|
||||
const definitions = { ...(old.definitions ?? {}) };
|
||||
const current = Object.hasOwn(definitions, name) ? definitions[name] : null;
|
||||
if (rev !== undefined && (current?.rev ?? null) !== rev) fail("definition-rev-conflict", `definition ${name} is not at rev ${rev}; read again`);
|
||||
const entry = next(current);
|
||||
if (entry === null) {
|
||||
if (!current) fail("definition-missing", `definitions declares no ${name}`);
|
||||
delete definitions[name];
|
||||
} else {
|
||||
definitions[name] = { ...entry, rev: current ? current.rev + 1 : 1 };
|
||||
logged.rev = definitions[name].rev;
|
||||
}
|
||||
const { definitions: _, ...rest } = old;
|
||||
return Object.keys(definitions).length ? { ...rest, definitions } : rest;
|
||||
}, { revision, actor, guard: (old, document) => { projectsProtected(old, document); guard(old, document); }, detail: logged });
|
||||
return { ...out, rev: logged.rev };
|
||||
}
|
||||
|
||||
// Lifecycle Task 2: the only writer that adds a project mapping, and only the exact bootstrap
|
||||
@@ -483,5 +538,5 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
});
|
||||
}
|
||||
|
||||
return { logFile, read, readRaw, write, addProject, setProjectRemote, setProjectAgent, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
|
||||
return { logFile, read, readRaw, write, addProject, setProjectRemote, setProjectAgent, setDefinition, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
|
||||
}
|
||||
@@ -4,13 +4,15 @@
|
||||
// Writes go through the organization writer, which logs old and new names
|
||||
// like a limit change. A write is a patch per kind (see set()): kinds it doesn't name are kept. A refused write throws a code and logs nothing.
|
||||
import { organizationWriter, OrganizationError } from "./organization.mjs";
|
||||
import { verifiedPlugin } from "../../plugins/src/pinned.mjs";
|
||||
import { hasModule } from "../../plugins/src/loader.mjs";
|
||||
import { isPlainObject } from "./util.mjs";
|
||||
import { KINDS, PluginError, checkPlugins, checkUsageSettings } from "../../plugins/src/kinds.mjs";
|
||||
|
||||
// `exists(kind, name)`, when given, refuses a name no module answers to (the host passes the
|
||||
// loader's hasModule). Without it the setter checks only the shape, as before.
|
||||
export function pluginSettings({ dir, rolesDir, exists = null }) {
|
||||
const writer = organizationWriter({ dir, rolesDir });
|
||||
export function pluginSettings({ dir, rolesDir, exists = null, dataRoot = null }) {
|
||||
const writer = organizationWriter({ dir, rolesDir, dataRoot });
|
||||
|
||||
function read(id) {
|
||||
const { document, revision } = writer.read(id);
|
||||
@@ -51,6 +53,10 @@ export function pluginSettings({ dir, rolesDir, exists = null }) {
|
||||
if (exists) {
|
||||
for (const [kind, setting] of Object.entries(sets)) {
|
||||
for (const name of Array.isArray(setting) ? setting : [setting]) {
|
||||
if (dataRoot !== null && !hasModule(kind,name)) {
|
||||
try { verifiedPlugin(dataRoot,name,kind); }
|
||||
catch(e) { throw new OrganizationError(e.code === "missing-lock" ? "unknown-plugin" : "invalid-plugins", "installed plugin verification refused"); }
|
||||
}
|
||||
if (!exists(kind, name)) throw new OrganizationError("unknown-plugin", `no ${kind} module named ${name} in this tree`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,20 +10,23 @@ export function projectFilePath(root) {
|
||||
return join(root, ".mosaic", "project.json");
|
||||
}
|
||||
|
||||
// Refusals name the project file by label: its path is the project root from
|
||||
// the business file, a config value, so no refusal quotes it (PR #1916). An
|
||||
// instance name is quoted only after requireId passed it.
|
||||
export function validateProjectDocument(document, file) {
|
||||
requireObject(document, "project file");
|
||||
rejectUnknownKeys(document, ["projectVersion", "id", "vars", "roles"], "project file");
|
||||
if (document.projectVersion !== 1) refuse(`project file "projectVersion" must be 1 (${file})`);
|
||||
if (document.projectVersion !== 1) refuse(`project file "projectVersion" must be 1`);
|
||||
requireId(document.id, "project id");
|
||||
const vars = checkVars(document.vars ?? {}, "project", `${file} vars`);
|
||||
const vars = checkVars(document.vars ?? {}, "project", "project file vars");
|
||||
const roles = {};
|
||||
if (document.roles !== undefined) {
|
||||
requireObject(document.roles, `${file} roles`);
|
||||
requireObject(document.roles, "project file roles");
|
||||
for (const [instance, entry] of Object.entries(document.roles)) {
|
||||
requireId(instance, `${file} role instance`);
|
||||
requireObject(entry, `${file} roles.${instance}`);
|
||||
rejectUnknownKeys(entry, ["vars"], `${file} roles.${instance}`);
|
||||
roles[instance] = { vars: checkVars(entry.vars ?? {}, "project", `${file} roles.${instance}.vars`) };
|
||||
requireId(instance, "project file role instance");
|
||||
requireObject(entry, `project file roles.${instance}`);
|
||||
rejectUnknownKeys(entry, ["vars"], `project file roles.${instance}`);
|
||||
roles[instance] = { vars: checkVars(entry.vars ?? {}, "project", `project file roles.${instance}.vars`) };
|
||||
}
|
||||
}
|
||||
return deepFreeze({ projectVersion: 1, id: document.id, file, vars, roles });
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// the record a launcher or the broker uses. Plain values: the most specific
|
||||
// layer wins. Limits: every layer narrows, nothing widens.
|
||||
|
||||
import { organizationPlugins, assertPluginCapabilities } from "../../plugins/src/organization.mjs";
|
||||
import { ownRails } from "./rails.mjs";
|
||||
import { refuse } from "./errors.mjs";
|
||||
import { ACTIONS, NETWORKS, isHostKind } from "./vocabulary.mjs";
|
||||
@@ -31,11 +32,18 @@ function narrowerNetwork(a, b) {
|
||||
// business loadBusiness(...) output
|
||||
// project loadProject(...) output, or null
|
||||
// instance a role instance the business file declares
|
||||
export function resolveInstance({ system, business, project = null, instance }) {
|
||||
export function resolveInstance({ system, business, project = null, instance, enforcePlugins = false, pluginRoot = system?.dataRoot ?? null }) {
|
||||
const entry = Object.hasOwn(business.roles, instance) ? business.roles[instance] : null;
|
||||
if (!entry) refuse(`business ${business.id} declares no role instance ${JSON.stringify(instance)}`);
|
||||
const definition = business.definitions[entry.definition];
|
||||
|
||||
const pluginView = organizationPlugins(pluginRoot,business.plugins);
|
||||
let disabledPlugin = null;
|
||||
try { assertPluginCapabilities(definition,pluginView); } catch(e) {
|
||||
if(enforcePlugins)throw e;
|
||||
disabledPlugin=e.plugin;
|
||||
}
|
||||
const pluginTools = pluginView.manifests.flatMap(m=>m.tools).filter(t=>definition.tools.includes(t.name));
|
||||
const layers = [
|
||||
{ layer: "system", source: "system", vars: system },
|
||||
{ layer: "business", source: `business:${business.id}`, vars: business.vars },
|
||||
@@ -79,6 +87,9 @@ export function resolveInstance({ system, business, project = null, instance })
|
||||
}
|
||||
|
||||
const resolved = {
|
||||
...(pluginTools.length ? {pluginTools} : {}),
|
||||
...(pluginView.registry.plugins.length ? {pluginActions:pluginView.registry.plugins.flatMap(p=>p.actions)} : {}),
|
||||
...(disabledPlugin ? {disabledPlugin} : {}),
|
||||
business: business.id,
|
||||
project: projectId,
|
||||
instance,
|
||||
@@ -105,6 +116,9 @@ export function resolveInstance({ system, business, project = null, instance })
|
||||
webSource: business.plugins?.["search-source"] ?? null,
|
||||
// The role file's skill names. The bundle resolves each against <dataRoot>/skills-enabled.
|
||||
skills: [...definition.skills],
|
||||
// #1873 T1: an org definition has no contract file; its instructions are the contract. Added
|
||||
// only then, so a template instance resolves, and digests, exactly as before.
|
||||
...(definition.contractPath === null && typeof definition.instructions === "string" ? { instructions: definition.instructions } : {}),
|
||||
};
|
||||
return deepFreeze({ ...resolved, digest: sha256(canonicalJson(resolved)) });
|
||||
}
|
||||
@@ -112,7 +126,7 @@ export function resolveInstance({ system, business, project = null, instance })
|
||||
// Classify one action for a resolved instance: "within", "cross" or
|
||||
// "gated". An action outside the vocabulary refuses.
|
||||
export function classify(resolved, action) {
|
||||
if (!ACTIONS.includes(action)) refuse(`unknown action: ${JSON.stringify(action)}`);
|
||||
if (!ACTIONS.includes(action) && !resolved.pluginActions?.includes(action)) refuse(`unknown action: ${JSON.stringify(action)}`);
|
||||
if (resolved.limits.authority.withinRole.includes(action)) return "within";
|
||||
if (resolved.limits.authority.crossRole.includes(action)) return "cross";
|
||||
return "gated";
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
requireObject, rejectUnknownKeys, requireId, requireString, requireDistinctList, readJsonFile, deepFreeze,
|
||||
} from "./util.mjs";
|
||||
|
||||
import { pluginName } from "../../plugins/src/organization.mjs";
|
||||
|
||||
const CONTRACT_NAME = /^[a-z0-9][a-z0-9._-]{0,60}\.md$/;
|
||||
const V1_KEYS = ["roleVersion", "name", "tools", "network"];
|
||||
const V2_REQUIRED = ["roleVersion", "name", "title", "contract", "tools", "network", "authority", "credentials"];
|
||||
@@ -24,7 +26,7 @@ const SKILL_NAME = /^[A-Za-z0-9._-]+$/;
|
||||
function checkTools(tools) {
|
||||
if (!Array.isArray(tools) || tools.length === 0) refuse('role "tools" must be a non-empty array of tool names');
|
||||
return requireDistinctList(tools, "role tools", (tool) => {
|
||||
if (!TOOLS.includes(tool)) refuse(`unsupported tool (supported: ${TOOLS.join(", ")})`);
|
||||
if (!TOOLS.includes(tool) && !pluginName(tool)) refuse(`unsupported tool (supported: ${TOOLS.join(", ")})`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -39,7 +41,7 @@ function checkAuthority(authority) {
|
||||
const lists = {};
|
||||
for (const key of ["withinRole", "crossRole"]) {
|
||||
lists[key] = requireDistinctList(authority[key], `role authority.${key}`, (action) => {
|
||||
if (!ACTIONS.includes(action)) refuse(`unknown action in authority.${key}; expected an action from the role vocabulary`);
|
||||
if (!ACTIONS.includes(action) && !pluginName(action)) refuse(`unknown action in authority.${key}; expected an action from the role vocabulary`);
|
||||
if (GATED_ONLY.includes(action)) refuse(`authority.${key} lists ${action}, which is always gated`);
|
||||
});
|
||||
}
|
||||
@@ -90,7 +92,7 @@ function checkHireable(hireable, file) {
|
||||
if (CREATIVE_DEFINITIONS.includes(name)) refuse(`hireable names ${name}, a creative definition only the human adds`);
|
||||
const path = join(dir, `${name}.json`);
|
||||
if (!existsSync(path)) refuse(`hireable names a definition which has no role file`);
|
||||
const named = validateRoleDocument(readJsonFile(path, "role file"), path, { hireable: false });
|
||||
const named = validateRoleDocument(readJsonFile(path, `role file ${name}.json`), path, { hireable: false });
|
||||
if (named.roleVersion !== 2) refuse(`hireable names a definition which is role version 1`);
|
||||
const outside = unhireableAction(named);
|
||||
if (outside !== null) refuse(`hireable names ${name}, which holds ${outside} outside the hireable ceiling`);
|
||||
@@ -132,13 +134,15 @@ function checkCredentials(credentials) {
|
||||
// Row 210 (#1707, design D1): "host" takes no scopes; the business file names the hosts.
|
||||
const host = entry.service === HOST_SERVICE;
|
||||
rejectUnknownKeys(entry, host ? ["service"] : ["service", "scopes"], `role credentials[${i}]`);
|
||||
if (!host && !SERVICES.includes(entry.service)) {
|
||||
if (!host && !SERVICES.includes(entry.service) && !pluginName(entry.service)) {
|
||||
refuse(`role credentials[${i}].service must be one of: ${[...SERVICES, HOST_SERVICE].join(", ")}`);
|
||||
}
|
||||
if (seen.has(entry.service)) refuse(`role credentials name ${entry.service} twice`);
|
||||
seen.add(entry.service);
|
||||
if (host) return { service: HOST_SERVICE };
|
||||
const scopes = entry.service === "gitea" ? checkGiteaScopes(entry.scopes) : checkVikunjaScopes(entry.scopes);
|
||||
const scopes = pluginName(entry.service) ? requireDistinctList(entry.scopes, "plugin credential scopes", s => {
|
||||
if (typeof s !== "string" || !/^[a-z][a-z0-9_.:-]{0,63}$/.test(s)) refuse("invalid plugin credential scope");
|
||||
}, {nonEmpty:true}) : entry.service === "gitea" ? checkGiteaScopes(entry.scopes) : checkVikunjaScopes(entry.scopes);
|
||||
return { service: entry.service, scopes };
|
||||
});
|
||||
}
|
||||
@@ -226,7 +230,7 @@ export function validateRoleDocument(document, file, options = {}) {
|
||||
}
|
||||
|
||||
export function loadRoleFile(file) {
|
||||
return validateRoleDocument(readJsonFile(file, "role file"), file);
|
||||
return validateRoleDocument(readJsonFile(file, /^[a-z0-9][a-z0-9._-]{0,63}\.json$/.test(basename(file)) ? `role file ${basename(file)}` : "role file"), file);
|
||||
}
|
||||
|
||||
export function loadRole(rolesDir, name) {
|
||||
|
||||
@@ -60,11 +60,14 @@ export function requireDate(value, where) {
|
||||
|
||||
// Parser diagnostics can quote file bytes. Keep the existing SyntaxError refusal,
|
||||
// but never attach the parser's message or cause to an error that callers can log.
|
||||
// The message names the file by its `what` label only: a refusal never quotes a
|
||||
// host path (PR #1916). `file` stays in the signature so callers don't change;
|
||||
// it is not used.
|
||||
export function parseJsonFileText(text, file, what = "file") {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
throw new SyntaxError(`${what} is not valid JSON (${file})`);
|
||||
throw new SyntaxError(`${what} is not valid JSON`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,19 +75,21 @@ export function parseJsonFileText(text, file, what = "file") {
|
||||
// Missing or not a regular file is 4; unparseable is 2. One descriptor,
|
||||
// opened without following a link, serves every check and the read.
|
||||
// `checkStat` sees that descriptor's stat before the read, so the file
|
||||
// can't be swapped between the check and the read.
|
||||
// can't be swapped between the check and the read. Every refusal names the
|
||||
// file by `what` and never by path; a caller whose label alone can't tell
|
||||
// two files apart adds a name that already passed a strict pattern.
|
||||
export function readJsonFile(file, what, checkStat) {
|
||||
let fd;
|
||||
try {
|
||||
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") refuse(`${what} not found: ${file}`, 4);
|
||||
refuse(`${what} must be a regular, non-symbolic-link file: ${file}`, 4);
|
||||
if (error.code === "ENOENT") refuse(`${what} not found`, 4);
|
||||
refuse(`${what} must be a regular, non-symbolic-link file`, 4);
|
||||
}
|
||||
let text;
|
||||
try {
|
||||
const stat = fstatSync(fd);
|
||||
if (!stat.isFile()) refuse(`${what} must be a regular, non-symbolic-link file: ${file}`, 4);
|
||||
if (!stat.isFile()) refuse(`${what} must be a regular, non-symbolic-link file`, 4);
|
||||
checkStat?.(stat);
|
||||
text = readFileSync(fd, "utf8");
|
||||
} finally {
|
||||
@@ -93,7 +98,7 @@ export function readJsonFile(file, what, checkStat) {
|
||||
try {
|
||||
return parseJsonFileText(text, file, what);
|
||||
} catch {
|
||||
refuse(`${what} is not valid JSON (${file})`);
|
||||
refuse(`${what} is not valid JSON`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -374,3 +374,12 @@ export function grantInScope(scope, body) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// PL1: a capability's plugin namespace, without loading files or code. Kept pure because the
|
||||
// runner's bus client imports the broker error type inside the existing sandbox binds.
|
||||
export function pluginName(capability) {
|
||||
const core = [...ACTIONS,...TOOLS,...SERVICES,HOST_SERVICE];
|
||||
if(typeof capability!=="string" || core.includes(capability) || !/^[a-z][a-z0-9-]*\.[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*$/.test(capability))return null;
|
||||
const name=capability.split(".")[0];
|
||||
return [...core,...NETWORKS].some(n=>n.split(".")[0]===name) ? null : name;
|
||||
}
|
||||
@@ -59,7 +59,8 @@ test("row 325: the old mosaic-stack project key refuses against those same proje
|
||||
const r = x.validate();
|
||||
assert.equal(r.status, 2, r.stdout + r.stderr);
|
||||
assert.equal(r.stdout, "");
|
||||
assert.match(r.stderr, /has id that does not match the declared project key/);
|
||||
assert.ok(r.stderr.includes(x.projectFile));
|
||||
assert.match(r.stderr, /project file of project mosaic-stack has id that does not match the declared project key/);
|
||||
// The refusal names the declared key, never the project root (PR #1916).
|
||||
assert.ok(!r.stderr.includes(x.projectFile), r.stderr);
|
||||
assert.doesNotMatch(r.stderr, /no project file/);
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { chmodSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { projectFilePath } from "../src/index.mjs";
|
||||
import { businessDoc, REPO, rolesCopy, systemConfig, tmp, tokenFile, writeJson } from "./helpers.mjs";
|
||||
@@ -87,11 +87,24 @@ test("validate: project files", () => {
|
||||
assert.equal(run(s, "validate", "acme").code, 2);
|
||||
});
|
||||
|
||||
// PR #1916 (org-redact5): the project root is a config value, so a mismatched
|
||||
// project file is named by its checked project key, never by path.
|
||||
test("validate: a project file with another id names the project key, not the project root", () => {
|
||||
const sentinel = "sk-test-NOT-A-SECRET-1234";
|
||||
const s = setup((doc, root) => { doc.projects.stack.root = join(root, sentinel, "project"); mkdirSync(doc.projects.stack.root, { recursive: true }); });
|
||||
writeJson(projectFilePath(s.doc.projects.stack.root), { projectVersion: 1, id: "other" });
|
||||
const r = run(s, "validate", "acme");
|
||||
assert.equal(r.code, 2);
|
||||
assert.match(r.err, /project file of project stack has id that does not match the declared project key/);
|
||||
assert.ok(!r.err.includes(sentinel), r.err);
|
||||
assert.ok(!r.err.includes(s.doc.projects.stack.root), r.err);
|
||||
});
|
||||
|
||||
test("validate: missing files and a broken system config", () => {
|
||||
const s = setup();
|
||||
const missing = run(s, "validate", "nobody");
|
||||
assert.equal(missing.code, 4);
|
||||
assert.match(missing.err, /business file not found/);
|
||||
assert.match(missing.err, /business file nobody\.json not found/);
|
||||
assert.equal(run(s, "validate", "Bad!").code, 2);
|
||||
|
||||
const noConfig = { ...s, env: { ...s.env, MOSAIC_CONFIG: join(s.root, "absent", "config.json") } };
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
// #1873 T1: organization definitions and templates (roles-and-rails design sections 4, 5 and 13).
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { appendFileSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { organizationWriter } from "../src/organization.mjs";
|
||||
import { validateBusinessDocument } from "../src/organization-config.mjs";
|
||||
import { definitionVerbs, DEFINITION_VERBS } from "../src/definition-verbs.mjs";
|
||||
import { templateHash, templateHashes, diffDefinition, lineDiff } from "../src/definitions.mjs";
|
||||
import { resolveInstance } from "../src/resolve.mjs";
|
||||
import { ROLE_RIGHTS, VERB_RIGHTS, normalizeRights } from "../src/org-rights.mjs";
|
||||
import { businessDoc, rolesCopy, systemFor, tmp, writeJson } from "./helpers.mjs";
|
||||
|
||||
const SECRET = "sk-test-NOT-A-SECRET-1234";
|
||||
// The credential store's check, as if it held SECRET.
|
||||
const assertClean = (value) => {
|
||||
if (String(value).includes(SECRET)) throw new Error(`credential-leak ${value}`);
|
||||
};
|
||||
const OWNER = { actor: "jason", rights: [...ROLE_RIGHTS.owner], agent: false };
|
||||
const MEMBER = { actor: "alice", rights: [...ROLE_RIGHTS.member], agent: false };
|
||||
const HIRER = { actor: "alice", rights: normalizeRights(ROLE_RIGHTS.member, { add: ["org.hire"] }), agent: false };
|
||||
const PLAIN = { role: { tools: ["read"], network: "none", authority: { withinRole: [], crossRole: [] }, credentials: [] }, instructions: "Read and report.\n" };
|
||||
|
||||
function fixture(t, edit = (doc) => doc) {
|
||||
const root = tmp();
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const dir = join(root, "config");
|
||||
const rolesDir = rolesCopy(root);
|
||||
const file = writeJson(join(dir, "businesses/acme.json"), edit(businessDoc(root)));
|
||||
const writer = organizationWriter({ dir, rolesDir });
|
||||
return { root, dir, file, rolesDir, writer, verbs: definitionVerbs({ writer, rolesDir, assertClean }) };
|
||||
}
|
||||
const rev = (f) => f.writer.read("acme").revision;
|
||||
const doc = (f) => JSON.parse(readFileSync(f.file, "utf8"));
|
||||
|
||||
test("T1 import pins the template hash and copies its primitives and contract", (t) => {
|
||||
const f = fixture(t);
|
||||
const hash = templateHash(f.rolesDir, "coder");
|
||||
assert.match(hash, /^[0-9a-f]{64}$/);
|
||||
const receipt = f.verbs.import("acme", { revision: rev(f), template: "coder" }, OWNER);
|
||||
assert.deepEqual(Object.keys(receipt).sort(), ["definition", "from", "organization", "rev", "revision", "transaction", "verb"]);
|
||||
assert.equal(receipt.rev, 1);
|
||||
assert.deepEqual(receipt.from, { template: "coder", sha256: hash });
|
||||
const copy = doc(f).definitions.coder;
|
||||
assert.deepEqual(copy.from, { template: "coder", sha256: hash });
|
||||
assert.equal(copy.rev, 1);
|
||||
assert.equal(copy.instructions, readFileSync(join(f.rolesDir, "coder.md"), "utf8"));
|
||||
const template = JSON.parse(readFileSync(join(f.rolesDir, "coder.json"), "utf8"));
|
||||
assert.deepEqual(copy.role.tools, template.tools);
|
||||
assert.equal(copy.role.network, template.network);
|
||||
assert.equal(receipt.revision, rev(f));
|
||||
// The hash read lists every version 2 template, and only those.
|
||||
const hashes = templateHashes(f.rolesDir);
|
||||
assert.ok(hashes.some((h) => h.template === "coder" && h.sha256 === hash));
|
||||
assert.ok(!hashes.some((h) => h.template === "conductor-policy"));
|
||||
// --as names the copy.
|
||||
const second = f.verbs.import("acme", { revision: rev(f), template: "coder", as: "coder-two" }, OWNER);
|
||||
assert.equal(second.definition, "coder-two");
|
||||
assert.throws(() => f.verbs.import("acme", { revision: rev(f), template: "coder" }, OWNER), { code: "definition-exists" });
|
||||
});
|
||||
|
||||
test("T1 the hash covers the template JSON and its contract together", (t) => {
|
||||
const f = fixture(t);
|
||||
const before = templateHash(f.rolesDir, "coder");
|
||||
appendFileSync(join(f.rolesDir, "coder.md"), "\nOne more line.\n");
|
||||
const contractChanged = templateHash(f.rolesDir, "coder");
|
||||
assert.notEqual(contractChanged, before);
|
||||
const json = JSON.parse(readFileSync(join(f.rolesDir, "coder.json"), "utf8"));
|
||||
writeJson(join(f.rolesDir, "coder.json"), { ...json, title: `${json.title} (renamed)` }, 0o644);
|
||||
assert.notEqual(templateHash(f.rolesDir, "coder"), contractChanged);
|
||||
});
|
||||
|
||||
test("T1 editing a template file changes no copy, and the diff read shows the change", (t) => {
|
||||
const f = fixture(t);
|
||||
f.verbs.import("acme", { revision: rev(f), template: "coder" }, OWNER);
|
||||
const bytes = readFileSync(f.file);
|
||||
const pinned = doc(f).definitions.coder.from.sha256;
|
||||
assert.deepEqual(f.verbs.diff("acme", "coder"), { organization: "acme", revision: rev(f), definition: "coder", rev: 1, from: { template: "coder", sha256: pinned }, current: pinned, changed: false, primitives: [], instructions: [] });
|
||||
|
||||
appendFileSync(join(f.rolesDir, "coder.md"), "Always run the suites twice.\n");
|
||||
const json = JSON.parse(readFileSync(join(f.rolesDir, "coder.json"), "utf8"));
|
||||
writeJson(join(f.rolesDir, "coder.json"), { ...json, network: "none" }, 0o644);
|
||||
assert.notEqual(json.network, "none", "the fixture must change network");
|
||||
|
||||
// No copy changed, and the file still loads.
|
||||
assert.deepEqual(readFileSync(f.file), bytes);
|
||||
assert.equal(f.writer.read("acme").document.definitions.coder.from.sha256, pinned);
|
||||
|
||||
const diff = f.verbs.diff("acme", "coder");
|
||||
assert.equal(diff.changed, true);
|
||||
assert.equal(diff.current, templateHash(f.rolesDir, "coder"));
|
||||
assert.notEqual(diff.current, pinned);
|
||||
assert.deepEqual(diff.primitives, ["network", "instructions"]);
|
||||
assert.deepEqual(diff.instructions.filter((l) => l.op !== " "), [{ op: "+", line: "Always run the suites twice." }]);
|
||||
// Nothing applied.
|
||||
assert.deepEqual(readFileSync(f.file), bytes);
|
||||
});
|
||||
|
||||
test("T1 diff read: an invented definition has nothing to compare, a missing template is named", (t) => {
|
||||
const f = fixture(t);
|
||||
f.verbs.invent("acme", { revision: rev(f), name: "scout", definition: PLAIN }, OWNER);
|
||||
assert.equal(f.verbs.diff("acme", "scout").changed, false);
|
||||
assert.equal(f.verbs.diff("acme", "scout").current, null);
|
||||
f.verbs.import("acme", { revision: rev(f), template: "proofreader" }, OWNER);
|
||||
rmSync(join(f.rolesDir, "proofreader.json"));
|
||||
const gone = diffDefinition(f.writer.read("acme").document, "proofreader", f.rolesDir);
|
||||
assert.equal(gone.templateMissing, true);
|
||||
assert.throws(() => f.verbs.diff("acme", "nobody"), { name: "BusinessError" });
|
||||
assert.deepEqual(lineDiff("a\nb", "a\nc"), [{ op: " ", line: "a" }, { op: "-", line: "b" }, { op: "+", line: "c" }]);
|
||||
});
|
||||
|
||||
test("T1 rev counts up on every write, and a stale write refuses", (t) => {
|
||||
const f = fixture(t);
|
||||
assert.equal(f.verbs.invent("acme", { revision: rev(f), name: "scout", definition: PLAIN }, OWNER).rev, 1);
|
||||
const edited = { ...PLAIN, instructions: "Read, then report.\n" };
|
||||
assert.equal(f.verbs.edit("acme", { revision: rev(f), rev: 1, name: "scout", definition: edited }, OWNER).rev, 2);
|
||||
assert.equal(f.verbs.edit("acme", { revision: rev(f), rev: 2, name: "scout", definition: { ...edited, personality: { summary: "Terse." } } }, OWNER).rev, 3);
|
||||
assert.equal(doc(f).definitions.scout.rev, 3);
|
||||
const bytes = readFileSync(f.file);
|
||||
// A stale rev, a stale file revision, an unchanged edit.
|
||||
assert.throws(() => f.verbs.edit("acme", { revision: rev(f), rev: 2, name: "scout", definition: PLAIN }, OWNER), { code: "definition-rev-conflict" });
|
||||
const old = rev(f);
|
||||
assert.throws(() => f.verbs.edit("acme", { revision: "0".repeat(64), rev: 3, name: "scout", definition: PLAIN }, OWNER), { code: "revision-conflict" });
|
||||
assert.throws(() => f.verbs.edit("acme", { revision: old, rev: 3, name: "scout", definition: { ...edited, personality: { summary: "Terse." } } }, OWNER), { code: "definition-unchanged" });
|
||||
assert.throws(() => f.verbs.edit("acme", { revision: old, name: "scout", definition: PLAIN }, OWNER), { code: "definition-rev-required" });
|
||||
assert.throws(() => f.verbs.remove("acme", { revision: old, rev: 1, name: "scout" }, OWNER), { code: "definition-rev-conflict" });
|
||||
assert.deepEqual(readFileSync(f.file), bytes);
|
||||
// Edit keeps from; remove takes the entry out.
|
||||
f.verbs.import("acme", { revision: rev(f), template: "coder" }, OWNER);
|
||||
const from = doc(f).definitions.coder.from;
|
||||
f.verbs.edit("acme", { revision: rev(f), rev: 1, name: "coder", definition: PLAIN }, OWNER);
|
||||
assert.deepEqual(doc(f).definitions.coder.from, from);
|
||||
const removed = f.verbs.remove("acme", { revision: rev(f), rev: 3, name: "scout" }, OWNER);
|
||||
assert.equal(removed.removedRev, 3);
|
||||
assert.ok(!Object.hasOwn(doc(f).definitions, "scout"));
|
||||
// Each write is logged with its verb, name and rev, never role text.
|
||||
const log = readFileSync(f.writer.logFile("acme"), "utf8").trim().split("\n").map(JSON.parse).filter((e) => e.state === "committed");
|
||||
assert.deepEqual(log.map((e) => [e.detail.verb, e.detail.definition, e.detail.rev]), [
|
||||
["definition.invent.console", "scout", 1], ["definition.edit.console", "scout", 2], ["definition.edit.console", "scout", 3],
|
||||
["definition.import.console", "coder", 1], ["definition.edit.console", "coder", 2], ["definition.remove.console", "scout", null],
|
||||
]);
|
||||
});
|
||||
|
||||
test("T1 invent and customize refuse what checkComposition refuses, with no value in the error", (t) => {
|
||||
const f = fixture(t);
|
||||
f.verbs.invent("acme", { revision: rev(f), name: "scout", definition: PLAIN }, OWNER);
|
||||
const bytes = readFileSync(f.file);
|
||||
const bad = [
|
||||
["held credential in instructions", { ...PLAIN, instructions: `Use ${SECRET} to log in.\n` }],
|
||||
["held credential in personality", { ...PLAIN, personality: { voice: `say ${SECRET}` } }],
|
||||
["unregistered tool", { ...PLAIN, role: { ...PLAIN.role, tools: [SECRET] } }],
|
||||
["unregistered service", { ...PLAIN, role: { ...PLAIN.role, credentials: [{ service: SECRET }] } }],
|
||||
["hire chain", { ...PLAIN, role: { ...PLAIN.role, authority: { withinRole: ["role.hire"], crossRole: [] } } }],
|
||||
["over bound", { ...PLAIN, instructions: "x".repeat(16385) }],
|
||||
["unknown entry key", { ...PLAIN, [SECRET]: 1 }],
|
||||
["unknown role key", { ...PLAIN, role: { ...PLAIN.role, [SECRET]: [] } }],
|
||||
];
|
||||
for (const [what, definition] of bad) {
|
||||
for (const call of [
|
||||
() => f.verbs.invent("acme", { revision: rev(f), name: "other", definition }, OWNER),
|
||||
() => f.verbs.edit("acme", { revision: rev(f), rev: 1, name: "scout", definition }, OWNER),
|
||||
]) {
|
||||
assert.throws(call, (e) => !e.message.includes(SECRET) && !e.message.includes("x".repeat(64)) && !String(e.stack).includes(SECRET), what);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(readFileSync(f.file), bytes);
|
||||
// Import refuses a template that holds a hire right (R9): the chief of staff.
|
||||
assert.throws(() => f.verbs.import("acme", { revision: rev(f), template: "chief-of-staff" }, OWNER), /cannot hire or fire/);
|
||||
assert.deepEqual(readFileSync(f.file), bytes);
|
||||
});
|
||||
|
||||
test("T1 a bad entry in the file refuses the whole file, naming the definition and key, never the value", (t) => {
|
||||
const root = tmp();
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const rolesDir = rolesCopy(root);
|
||||
const file = join(root, "config/businesses/acme.json");
|
||||
const base = businessDoc(root);
|
||||
const entry = { rev: 1, ...PLAIN };
|
||||
const refusal = (definitions) => {
|
||||
try {
|
||||
validateBusinessDocument({ ...base, definitions }, file, { rolesDir, assertClean });
|
||||
} catch (e) {
|
||||
assert.equal(e.name, "BusinessError");
|
||||
return e.message;
|
||||
}
|
||||
assert.fail("expected a refusal");
|
||||
};
|
||||
assert.match(refusal({ scout: { ...entry, instructions: `see ${SECRET}` } }), /definitions\.scout: composition instructions holds a credential/);
|
||||
assert.match(refusal({ scout: { ...entry, role: { ...PLAIN.role, tools: [SECRET] } } }), /definitions\.scout: .*tools/);
|
||||
assert.match(refusal({ scout: { ...entry, rev: 0 } }), /definitions\.scout\.rev must be a positive integer/);
|
||||
assert.match(refusal({ scout: { ...entry, from: { template: "coder", sha256: SECRET } } }), /definitions\.scout\.from\.sha256/);
|
||||
assert.match(refusal({ scout: { ...entry, [SECRET]: true } }), /definitions\.scout has an unknown key \(key \d+;/);
|
||||
assert.match(refusal({ [SECRET]: entry }), /definitions key 1 must be a definition name/);
|
||||
for (const d of [{ scout: { ...entry, instructions: `see ${SECRET}` } }, { scout: { ...entry, role: { ...PLAIN.role, tools: [SECRET] } } }, { [SECRET]: entry }, { scout: { ...entry, [SECRET]: true } }]) {
|
||||
assert.ok(!refusal(d).includes(SECRET));
|
||||
}
|
||||
// A good definition validates; the resolved map holds only what instances name.
|
||||
const ok = validateBusinessDocument({ ...base, definitions: { scout: entry } }, file, { rolesDir });
|
||||
assert.ok(!Object.hasOwn(ok.definitions, "org:scout"));
|
||||
});
|
||||
|
||||
test("T1 an org: reference resolves to the definition, and a missing one refuses naming instance and definition", (t) => {
|
||||
const root = tmp();
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const rolesDir = rolesCopy(root);
|
||||
const file = join(root, "config/businesses/acme.json");
|
||||
const base = businessDoc(root);
|
||||
const named = { ...base, definitions: { scout: { rev: 4, ...PLAIN, personality: { summary: "Terse." } } }, roles: { ...base.roles, helper: { definition: "org:scout" } } };
|
||||
const business = validateBusinessDocument(named, file, { rolesDir });
|
||||
const role = business.definitions["org:scout"];
|
||||
assert.equal(role.name, "org:scout");
|
||||
assert.equal(role.contractPath, null);
|
||||
assert.equal(role.instructions, PLAIN.instructions);
|
||||
assert.equal(role.rev, 4);
|
||||
const resolved = resolveInstance({ system: systemFor(root), business, instance: "helper" });
|
||||
assert.equal(resolved.definition, "org:scout");
|
||||
assert.equal(resolved.contract, null);
|
||||
assert.equal(resolved.instructions, PLAIN.instructions);
|
||||
assert.deepEqual(resolved.limits.tools, ["read"]);
|
||||
|
||||
const missing = { ...base, roles: { ...base.roles, helper: { definition: "org:ghost" } } };
|
||||
assert.throws(() => validateBusinessDocument(missing, file, { rolesDir }), (e) => /definition-missing: .*roles\.helper\.definition names org:ghost/.test(e.message));
|
||||
assert.throws(() => validateBusinessDocument({ ...base, roles: { ...base.roles, helper: { definition: `org:${SECRET.toLowerCase()}!` } } }, file, { rolesDir }), (e) => !e.message.includes(SECRET.toLowerCase()));
|
||||
// Template names work exactly as before, including one shaped like a definition name.
|
||||
assert.throws(() => validateBusinessDocument({ ...base, roles: { ...base.roles, helper: { definition: "scout" } } }, file, { rolesDir }), { name: "BusinessError" });
|
||||
});
|
||||
|
||||
test("T1 an org whose instances name only templates loads exactly as before", (t) => {
|
||||
const root = tmp();
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const rolesDir = rolesCopy(root);
|
||||
const file = join(root, "config/businesses/acme.json");
|
||||
const base = businessDoc(root);
|
||||
const plain = validateBusinessDocument(base, file, { rolesDir });
|
||||
const withUnused = validateBusinessDocument({ ...base, definitions: { scout: { rev: 1, ...PLAIN } } }, file, { rolesDir });
|
||||
assert.deepEqual(withUnused, plain);
|
||||
assert.deepEqual(Object.keys(plain).sort(), ["arbiters", "businessVersion", "definitions", "file", "human", "id", "launch", "plugins", "projects", "roles", "tracker", "vars"]);
|
||||
assert.deepEqual(Object.keys(plain.definitions).sort(), ["chief-of-staff", "coder", "cto", "pm", "reviewer"]);
|
||||
const system = systemFor(root);
|
||||
for (const instance of Object.keys(base.roles)) {
|
||||
const a = resolveInstance({ system, business: plain, instance });
|
||||
const b = resolveInstance({ system, business: withUnused, instance });
|
||||
assert.equal(a.digest, b.digest);
|
||||
assert.ok(!Object.hasOwn(a, "instructions"));
|
||||
assert.equal(a.contract, plain.definitions[base.roles[instance].definition].contractPath);
|
||||
}
|
||||
});
|
||||
|
||||
test("T1 definition-in-use: an edit or removal of a definition an instance names refuses, naming it", (t) => {
|
||||
const f = fixture(t);
|
||||
f.verbs.invent("acme", { revision: rev(f), name: "scout", definition: PLAIN }, OWNER);
|
||||
// A human adds the instance through the ordinary writer; definitions stay as they are.
|
||||
const next = doc(f);
|
||||
next.roles.helper = { definition: "org:scout" };
|
||||
f.writer.write("acme", next, { revision: rev(f), actor: "jason" });
|
||||
const bytes = readFileSync(f.file);
|
||||
assert.throws(() => f.verbs.edit("acme", { revision: rev(f), rev: 1, name: "scout", definition: { ...PLAIN, instructions: "Changed.\n" } }, OWNER),
|
||||
(e) => e.code === "definition-in-use" && /named by helper/.test(e.message));
|
||||
assert.throws(() => f.verbs.remove("acme", { revision: rev(f), rev: 1, name: "scout" }, OWNER),
|
||||
(e) => e.code === "definition-in-use" && /named by helper/.test(e.message));
|
||||
assert.deepEqual(readFileSync(f.file), bytes);
|
||||
assert.deepEqual(f.verbs.list("acme").definitions, [{ name: "scout", reference: "org:scout", rev: 1, from: null, usedBy: ["helper"] }]);
|
||||
assert.deepEqual(f.verbs.show("acme", "scout").usedBy, ["helper"]);
|
||||
});
|
||||
|
||||
test("T1 the ordinary writer cannot change definitions, and setDefinition changes nothing else", (t) => {
|
||||
const f = fixture(t);
|
||||
f.verbs.invent("acme", { revision: rev(f), name: "scout", definition: PLAIN }, OWNER);
|
||||
const next = doc(f);
|
||||
next.definitions.scout.instructions = "Sneaked in.\n";
|
||||
next.definitions.scout.rev = 2;
|
||||
assert.throws(() => f.writer.write("acme", next, { revision: rev(f), actor: "jason" }), { code: "organization-definitions-protected" });
|
||||
const added = doc(f);
|
||||
added.definitions.other = { rev: 1, ...PLAIN };
|
||||
assert.throws(() => f.writer.write("acme", added, { revision: rev(f), actor: "jason" }), { code: "organization-definitions-protected" });
|
||||
// The writer itself checks rev and the composition, whatever builds the entry.
|
||||
assert.throws(() => f.writer.setDefinition("acme", "scout", () => ({ ...PLAIN, role: { ...PLAIN.role, tools: ["no-such-tool"] } }), { revision: rev(f), actor: "jason", assertClean }), { name: "BusinessError" });
|
||||
});
|
||||
|
||||
test("T1 an agent cannot call the edit verbs", (t) => {
|
||||
const f = fixture(t);
|
||||
f.verbs.invent("acme", { revision: rev(f), name: "scout", definition: PLAIN }, OWNER);
|
||||
const bytes = readFileSync(f.file);
|
||||
const calls = (caller) => [
|
||||
() => f.verbs.import("acme", { revision: rev(f), template: "coder" }, caller),
|
||||
() => f.verbs.invent("acme", { revision: rev(f), name: "other", definition: PLAIN }, caller),
|
||||
() => f.verbs.edit("acme", { revision: rev(f), rev: 1, name: "scout", definition: { ...PLAIN, instructions: "x\n" } }, caller),
|
||||
() => f.verbs.remove("acme", { revision: rev(f), rev: 1, name: "scout" }, caller),
|
||||
];
|
||||
// An agent session, even one holding every right; an agent's name or the tester as actor.
|
||||
for (const caller of [
|
||||
{ ...OWNER, agent: true }, { actor: "coder", rights: OWNER.rights }, { ...OWNER, actor: "coder" },
|
||||
{ ...OWNER, actor: "coder.coder" }, { ...OWNER, actor: "tester" }, { ...OWNER, actor: "chief" },
|
||||
]) {
|
||||
for (const call of calls(caller)) assert.throws(call, { code: "human-required" });
|
||||
}
|
||||
// The writer refuses an agent actor by itself too.
|
||||
assert.throws(() => f.writer.setDefinition("acme", "other", () => PLAIN, { revision: rev(f), actor: "pm" }), { code: "human-required" });
|
||||
assert.deepEqual(readFileSync(f.file), bytes);
|
||||
});
|
||||
|
||||
test("T1 a member without org.hire refuses on each verb; the owner and a member granted org.hire succeed", (t) => {
|
||||
const f = fixture(t);
|
||||
for (const verb of Object.values(DEFINITION_VERBS)) assert.equal(VERB_RIGHTS[verb], "org.hire");
|
||||
f.verbs.invent("acme", { revision: rev(f), name: "scout", definition: PLAIN }, OWNER);
|
||||
const bytes = readFileSync(f.file);
|
||||
const refused = [
|
||||
() => f.verbs.import("acme", { revision: rev(f), template: "coder" }, MEMBER),
|
||||
() => f.verbs.invent("acme", { revision: rev(f), name: "other", definition: PLAIN }, MEMBER),
|
||||
() => f.verbs.edit("acme", { revision: rev(f), rev: 1, name: "scout", definition: { ...PLAIN, instructions: "x\n" } }, MEMBER),
|
||||
() => f.verbs.remove("acme", { revision: rev(f), rev: 1, name: "scout" }, MEMBER),
|
||||
() => f.verbs.invent("acme", { revision: rev(f), name: "other", definition: PLAIN }, { actor: "alice", agent: false }),
|
||||
];
|
||||
for (const call of refused) assert.throws(call, (e) => e.code === "right-required" && /needs org\.hire/.test(e.message));
|
||||
assert.deepEqual(readFileSync(f.file), bytes);
|
||||
// Owner: every verb.
|
||||
f.verbs.import("acme", { revision: rev(f), template: "coder" }, OWNER);
|
||||
f.verbs.edit("acme", { revision: rev(f), rev: 1, name: "scout", definition: { ...PLAIN, instructions: "x\n" } }, OWNER);
|
||||
f.verbs.remove("acme", { revision: rev(f), rev: 2, name: "scout" }, OWNER);
|
||||
// A member granted org.hire: every verb, logged as that member.
|
||||
f.verbs.import("acme", { revision: rev(f), template: "reviewer" }, HIRER);
|
||||
f.verbs.invent("acme", { revision: rev(f), name: "scout", definition: PLAIN }, HIRER);
|
||||
f.verbs.edit("acme", { revision: rev(f), rev: 1, name: "scout", definition: { ...PLAIN, instructions: "y\n" } }, HIRER);
|
||||
f.verbs.remove("acme", { revision: rev(f), rev: 2, name: "scout" }, HIRER);
|
||||
const log = readFileSync(f.writer.logFile("acme"), "utf8").trim().split("\n").map(JSON.parse).filter((e) => e.state === "committed");
|
||||
assert.deepEqual(log.slice(-4).map((e) => e.actor), ["alice", "alice", "alice", "alice"]);
|
||||
});
|
||||
|
||||
test("T1 the template files on disk are never written", (t) => {
|
||||
const f = fixture(t);
|
||||
const before = readFileSync(join(f.rolesDir, "coder.json"));
|
||||
f.verbs.import("acme", { revision: rev(f), template: "coder" }, OWNER);
|
||||
f.verbs.edit("acme", { revision: rev(f), rev: 1, name: "coder", definition: PLAIN }, OWNER);
|
||||
assert.deepEqual(readFileSync(join(f.rolesDir, "coder.json")), before);
|
||||
writeFileSync(join(f.root, "touched"), "");
|
||||
});
|
||||
|
||||
test("T1 a write that adds or changes a definition refuses without the credential store's secret check", (t) => {
|
||||
const f = fixture(t);
|
||||
f.verbs.invent("acme", { revision: rev(f), name: "scout", definition: PLAIN }, OWNER);
|
||||
const unchecked = definitionVerbs({ writer: f.writer, rolesDir: f.rolesDir });
|
||||
const before = readFileSync(f.file);
|
||||
const required = { code: "definition-secret-check-required" };
|
||||
assert.throws(() => unchecked.import("acme", { revision: rev(f), template: "coder" }, OWNER), required);
|
||||
assert.throws(() => unchecked.invent("acme", { revision: rev(f), name: "other", definition: PLAIN }, OWNER), required);
|
||||
assert.throws(() => unchecked.edit("acme", { revision: rev(f), rev: 1, name: "scout", definition: { ...PLAIN, instructions: "Report.\n" } }, OWNER), required);
|
||||
assert.throws(() => f.writer.setDefinition("acme", "other", () => PLAIN, { revision: rev(f), actor: "jason" }), required);
|
||||
assert.deepEqual(readFileSync(f.file), before);
|
||||
// A removal writes no role text, so it needs no secret check.
|
||||
assert.equal(unchecked.remove("acme", { revision: rev(f), rev: 1, name: "scout" }, OWNER).removedRev, 1);
|
||||
});
|
||||
@@ -17,42 +17,46 @@ import { createOrg, deleteOrg, notificationShow, showOrg, templateRender } from
|
||||
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
const malformed = secret;
|
||||
// PR #1916 redaction (org-redact5): the sentinel is also a path component, so a
|
||||
// refusal that quoted the host path would carry it.
|
||||
function fixture(t) {
|
||||
const root = tmp();
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const top = tmp();
|
||||
t.after(() => rmSync(top, { recursive: true, force: true }));
|
||||
const root = join(top, secret);
|
||||
mkdirSync(root, { mode: 0o700 });
|
||||
const ctx = { configDir: join(root, 'config'), dataRoot: join(root, 'data'), instance: 'test',
|
||||
principal: { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' } };
|
||||
for (const dir of [ctx.configDir, ctx.dataRoot]) mkdirSync(dir, { mode: 0o700 });
|
||||
return ctx;
|
||||
}
|
||||
function safe(file, { name = 'SyntaxError', code, exitCode, prefix } = {}) {
|
||||
// The refusal names the file by its label and never by path.
|
||||
function safe(file, { name = 'SyntaxError', code, exitCode, prefix, label, suffix = '' } = {}) {
|
||||
return error => {
|
||||
assert.equal(error.name, name);
|
||||
assert.equal(error.code, code);
|
||||
assert.equal(error.exitCode, exitCode);
|
||||
assert.ok(error.message.includes(file), error.message);
|
||||
assert.match(error.message, /not valid JSON/);
|
||||
assert.ok(!error.message.includes(file), error.message);
|
||||
assert.equal(error.message, `${prefix ?? ''}${label} is not valid JSON${suffix}`);
|
||||
assert.doesNotMatch(error.message, /sk-test|private|broken|Unexpected|position|line \d/);
|
||||
assert.ok(!error.stack.includes(secret));
|
||||
assert.equal(error.cause, undefined);
|
||||
if (prefix) assert.ok(error.message.startsWith(prefix));
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
for (const [label, load, relative] of [
|
||||
['shared reader', file => readJsonFile(file, 'config file'), 'config.json'],
|
||||
['organization loader', (_file, ctx) => loadBusiness('alpha', { dir: ctx.configDir, rolesDir: REPO_ROLES }), 'businesses/alpha.json'],
|
||||
['legacy business loader', (_file, ctx) => legacyLoadBusiness('alpha', { dir: ctx.configDir, rolesDir: REPO_ROLES }), 'businesses/alpha.json'],
|
||||
['project loader', (_file, ctx) => loadProject(ctx.configDir), '.mosaic/project.json'],
|
||||
for (const [label, load, relative, labelText] of [
|
||||
['shared reader', file => readJsonFile(file, 'config file'), 'config.json', 'config file'],
|
||||
['organization loader', (_file, ctx) => loadBusiness('alpha', { dir: ctx.configDir, rolesDir: REPO_ROLES }), 'businesses/alpha.json', 'business file alpha.json'],
|
||||
['legacy business loader', (_file, ctx) => legacyLoadBusiness('alpha', { dir: ctx.configDir, rolesDir: REPO_ROLES }), 'businesses/alpha.json', 'business file alpha.json'],
|
||||
['project loader', (_file, ctx) => loadProject(ctx.configDir), '.mosaic/project.json', 'project file'],
|
||||
]) test(`${label} names malformed JSON's file without quoting bytes`, t => {
|
||||
const ctx = fixture(t), file = writeJson(join(ctx.configDir, relative), malformed);
|
||||
assert.throws(() => load(file, ctx), safe(file, { name: 'BusinessError', exitCode: 2 }));
|
||||
assert.throws(() => load(file, ctx), safe(file, { name: 'BusinessError', exitCode: 2, label: labelText }));
|
||||
});
|
||||
|
||||
test('root agents keep their existing file-only refusal', t => {
|
||||
const ctx = fixture(t); writeJson(join(ctx.configDir, 'agents.json'), malformed);
|
||||
assert.throws(() => loadRootAgents(ctx.configDir, { rolesDir: REPO_ROLES }), safe('agents.json', { name: 'BusinessError', exitCode: 2 }));
|
||||
assert.throws(() => loadRootAgents(ctx.configDir, { rolesDir: REPO_ROLES }), safe(join(ctx.configDir, 'agents.json'), { name: 'BusinessError', exitCode: 2, label: 'root agent file', suffix: ' (agents.json)' }));
|
||||
});
|
||||
|
||||
for (const operation of ['read', 'write', 'recover', 'findAddition']) test(`organization writer ${operation} does not quote malformed file bytes`, async t => {
|
||||
@@ -74,13 +78,13 @@ for (const operation of ['read', 'write', 'recover', 'findAddition']) test(`orga
|
||||
const invoke = () => operation === 'write'
|
||||
? writer.write('alpha', current.document, { revision: current.revision, actor: ctx.principal.sub })
|
||||
: writer[operation]('alpha', {});
|
||||
assert.throws(invoke, safe(file));
|
||||
assert.throws(invoke, safe(file, { label: receipt ? 'organization receipt file' : 'organization file' }));
|
||||
});
|
||||
|
||||
for (const operation of ['async', 'sync']) test(`${operation} registry read names the file without parser bytes`, async t => {
|
||||
const ctx = fixture(t), file = writeJson(join(ctx.configDir, 'org-runtime.json'), malformed);
|
||||
if (operation === 'async') await assert.rejects(readRegistry(ctx), safe(file, { name: 'OrgError', code: 'org-mismatch', prefix: 'invalid registry: ' }));
|
||||
else assert.throws(() => withRegistryWriter(ctx.configDir, () => assert.fail('must refuse')), safe(file));
|
||||
if (operation === 'async') await assert.rejects(readRegistry(ctx), safe(file, { name: 'OrgError', code: 'org-mismatch', prefix: 'invalid registry: ', label: 'metadata file' }));
|
||||
else assert.throws(() => withRegistryWriter(ctx.configDir, () => assert.fail('must refuse')), safe(file, { label: 'registry file' }));
|
||||
});
|
||||
|
||||
for (const kind of ['owner', 'organization', 'notifier', 'deletion receipt']) test(`service ${kind} refuses malformed JSON without file bytes`, async t => {
|
||||
@@ -107,9 +111,11 @@ for (const kind of ['owner', 'organization', 'notifier', 'deletion receipt']) te
|
||||
registry.orgs.alpha.digest = createHash('sha256').update(malformed).digest('hex');
|
||||
writeJson(registryFile, registry);
|
||||
}
|
||||
if (kind === 'notifier') await assert.rejects(notificationShow(ctx, 'alpha'), safe(file));
|
||||
else if (kind === 'deletion receipt') await assert.rejects(deleteOrg(ctx, 'alpha', { confirm: 'alpha', ifRevision: 1, busStop: async () => assert.fail('must refuse first') }), safe(file));
|
||||
else await assert.rejects(showOrg(ctx, 'alpha'), safe(file));
|
||||
const label = { owner: 'organization owner file', organization: 'organization file', notifier: 'notifier configuration file', 'deletion receipt': 'deletion receipt file' }[kind];
|
||||
if (kind === 'notifier') await assert.rejects(notificationShow(ctx, 'alpha'), safe(file, { label }));
|
||||
else if (kind === 'deletion receipt') await assert.rejects(deleteOrg(ctx, 'alpha', { confirm: 'alpha', ifRevision: 1, busStop: async () => assert.fail('must refuse first') }),
|
||||
safe(file, { label, suffix: '; deletion remains in progress. Inspect the deletion receipt under the data root and rerun mosaic org delete alpha --confirm alpha' }));
|
||||
else await assert.rejects(showOrg(ctx, 'alpha'), safe(file, { label }));
|
||||
});
|
||||
|
||||
// The packaged template is immutable at runtime; substitute only its read result
|
||||
@@ -122,7 +128,7 @@ test('template reader refuses malformed JSON without quoting template bytes', as
|
||||
});
|
||||
syncBuiltinESMExports();
|
||||
try {
|
||||
await assert.rejects(templateRender('alpha'), safe('org-template.json'));
|
||||
await assert.rejects(templateRender('alpha'), safe('/org-template.json', { label: 'organization template file' }));
|
||||
} finally {
|
||||
t.mock.restoreAll();
|
||||
syncBuiltinESMExports();
|
||||
|
||||
@@ -60,6 +60,10 @@ const VERBS = {
|
||||
'decision.preview.console': 'decide',
|
||||
'decision.resolve.console': 'decide',
|
||||
'hire.resolve.console': 'org.hire',
|
||||
'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',
|
||||
@@ -186,6 +190,8 @@ test('M1 every actual console, user, board and person-task verb has a reviewed a
|
||||
assert.deepEqual(Object.keys(VERBS).sort(), [...new Set([
|
||||
...CONSOLE_VERBS, ...USER_VERBS, ...BOARD_VERBS, ...PERSON_TASK_VERBS, 'plugins', 'plugins.set', 'limits',
|
||||
'hire.resolve.console', // R1 reserves the policy mapping; A4 implements the verb.
|
||||
// T1: the definition verbs. The CLI calls them today; the Console route comes later.
|
||||
'definition.import.console', 'definition.edit.console', 'definition.invent.console', 'definition.remove.console',
|
||||
])].sort());
|
||||
});
|
||||
|
||||
|
||||
@@ -192,7 +192,7 @@ test("REQ-THREAD-2 REQ-LAUNCH-1 launch.threadIdleSeconds: absent is the default,
|
||||
test("loadBusiness: file checks", () => {
|
||||
const root = tmp();
|
||||
const dir = join(root, "config");
|
||||
refuses(() => loadBusiness("acme", { dir, rolesDir: REPO_ROLES }), /business file not found/, 4);
|
||||
refuses(() => loadBusiness("acme", { dir, rolesDir: REPO_ROLES }), /business file acme\.json not found/, 4);
|
||||
|
||||
const file = writeJson(join(dir, "businesses", "acme.json"), businessDoc(root));
|
||||
assert.equal(loadBusiness("acme", { dir, rolesDir: REPO_ROLES }).file, file);
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
// PR #1916 redaction (org-redact5): a 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 { chmodSync, copyFileSync, mkdirSync, readFileSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { REPO_ROLES, tmp, writeJson } from './helpers.mjs';
|
||||
import { parseJsonFileText, readJsonFile } from '../src/util.mjs';
|
||||
import { loadAccess } from '../src/access.mjs';
|
||||
import { validateProjectDocument } from '../src/project.mjs';
|
||||
import { loadSeatSets } from '../src/library-seats.mjs';
|
||||
import { loadRole, loadRoleFile } from '../src/role.mjs';
|
||||
import { loadBusiness } from '../src/organization-config.mjs';
|
||||
import { createOrg, deleteOrg, notificationShow, templateRender } from '../src/org-service.mjs';
|
||||
import { checkCommands } from '../src/hosts.mjs';
|
||||
import { BUSINESS_CONFIG, runConfigCommand } from '../src/config-command.mjs';
|
||||
|
||||
const SENTINEL = 'sk-test-NOT-A-SECRET-1234';
|
||||
|
||||
function under(t) {
|
||||
const top = tmp();
|
||||
t.after(() => rmSync(top, { recursive: true, force: true }));
|
||||
const root = join(top, SENTINEL);
|
||||
mkdirSync(root, { mode: 0o700 });
|
||||
return root;
|
||||
}
|
||||
|
||||
// The refusal is exactly `message`, with its exit code, and carries neither the
|
||||
// sentinel nor any of `paths`.
|
||||
function refusal(message, { exitCode, name = 'BusinessError', paths = [] } = {}) {
|
||||
return (error) => {
|
||||
assert.equal(error.name, name);
|
||||
if (exitCode !== undefined) assert.equal(error.exitCode, exitCode);
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
test('readJsonFile: a missing file is refused by label, exit 4', (t) => {
|
||||
const file = join(under(t), 'config.json');
|
||||
assert.throws(() => readJsonFile(file, 'config file'), refusal('config file not found', { exitCode: 4, paths: [file] }));
|
||||
});
|
||||
|
||||
test('readJsonFile: a symbolic link is refused by label at open, exit 4', (t) => {
|
||||
const root = under(t), file = join(root, 'config.json');
|
||||
writeJson(join(root, 'real.json'), {});
|
||||
symlinkSync(join(root, 'real.json'), file);
|
||||
assert.throws(() => readJsonFile(file, 'config file'), refusal('config file must be a regular, non-symbolic-link file', { exitCode: 4, paths: [file] }));
|
||||
});
|
||||
|
||||
test('readJsonFile: a directory is refused by label after the stat, exit 4', (t) => {
|
||||
const file = join(under(t), 'config.json');
|
||||
mkdirSync(file);
|
||||
assert.throws(() => readJsonFile(file, 'config file'), refusal('config file must be a regular, non-symbolic-link file', { exitCode: 4, paths: [file] }));
|
||||
});
|
||||
|
||||
test('readJsonFile: invalid JSON is refused by label, exit 2', (t) => {
|
||||
const file = writeJson(join(under(t), 'config.json'), '{not json');
|
||||
assert.throws(() => readJsonFile(file, 'config file'), refusal('config file is not valid JSON', { exitCode: 2, paths: [file] }));
|
||||
});
|
||||
|
||||
test('parseJsonFileText: the SyntaxError names the label only', (t) => {
|
||||
const file = join(under(t), 'config.json');
|
||||
assert.throws(() => parseJsonFileText('{not json', file, 'config file'), refusal('config file is not valid JSON', { name: 'SyntaxError', paths: [file] }));
|
||||
});
|
||||
|
||||
test('loadAccess: an access file of another uid is refused without its path', (t) => {
|
||||
const file = writeJson(join(under(t), 'access.json'), {});
|
||||
const uid = process.getuid() + 1;
|
||||
assert.throws(() => loadAccess(file, {}, { uid }), refusal(`access file must belong to uid ${uid}`, { exitCode: 2, paths: [file] }));
|
||||
});
|
||||
|
||||
test('loadAccess: a group-writable access file is refused without its path', (t) => {
|
||||
const file = writeJson(join(under(t), 'access.json'), {}, 0o620);
|
||||
assert.throws(() => loadAccess(file, {}), refusal('access file must not be writable by group or other (mode 620)', { exitCode: 2, paths: [file] }));
|
||||
});
|
||||
|
||||
for (const [what, document, message] of [
|
||||
['projectVersion', { projectVersion: 2, id: 'stack' }, 'project file "projectVersion" must be 1'],
|
||||
['vars', { projectVersion: 1, id: 'stack', vars: [] }, 'project file vars must be a JSON object'],
|
||||
['roles', { projectVersion: 1, id: 'stack', roles: [] }, 'project file roles must be a JSON object'],
|
||||
['role instance', { projectVersion: 1, id: 'stack', roles: { 'Bad!': {} } }, 'project file role instance must match /^[a-z0-9][a-z0-9._-]{0,63}$/'],
|
||||
['roles entry', { projectVersion: 1, id: 'stack', roles: { coder: [] } }, 'project file roles.coder must be a JSON object'],
|
||||
['roles entry key', { projectVersion: 1, id: 'stack', roles: { coder: { extra: 1 } } }, 'unsupported project file roles.coder key: "extra"'],
|
||||
['roles entry vars', { projectVersion: 1, id: 'stack', roles: { coder: { vars: [] } } }, 'project file roles.coder.vars must be a JSON object'],
|
||||
]) test(`validateProjectDocument: ${what} is refused by label, never by the project root`, (t) => {
|
||||
const file = join(under(t), 'project', '.mosaic', 'project.json');
|
||||
assert.throws(() => validateProjectDocument(document, file), refusal(message, { exitCode: 2, paths: [file] }));
|
||||
});
|
||||
|
||||
test('loadSeatSets: a config directory that is a file is refused by its error code', (t) => {
|
||||
const dir = join(under(t), 'config');
|
||||
writeFileSync(dir, '');
|
||||
assert.throws(() => loadSeatSets({ dir, rolesDir: REPO_ROLES }), refusal('cannot list businesses in the config directory: ENOTDIR', { exitCode: 2, paths: [dir] }));
|
||||
});
|
||||
|
||||
test('loadSeatSets: a businesses entry that does not list is refused by its error code', (t) => {
|
||||
const dir = join(under(t), 'config');
|
||||
mkdirSync(dir);
|
||||
writeFileSync(join(dir, 'businesses'), '');
|
||||
assert.throws(() => loadSeatSets({ dir, rolesDir: REPO_ROLES }), refusal('cannot list businesses in the config directory: ENOTDIR', { exitCode: 2, paths: [dir] }));
|
||||
});
|
||||
|
||||
test('loadRole: a missing role file is named by its checked name', (t) => {
|
||||
const dir = under(t);
|
||||
assert.throws(() => loadRole(dir, 'coder'), refusal('role file coder.json not found', { exitCode: 4, paths: [dir] }));
|
||||
});
|
||||
|
||||
test('loadRoleFile: a file name outside the id pattern is not quoted', (t) => {
|
||||
const file = join(under(t), 'Not An Id.json');
|
||||
assert.throws(() => loadRoleFile(file), refusal('role file not found', { exitCode: 4, paths: [file] }));
|
||||
});
|
||||
|
||||
test('loadRoleFile: a hireable entry whose file is invalid JSON is named by its checked name', (t) => {
|
||||
const dir = join(under(t), 'roles');
|
||||
mkdirSync(dir);
|
||||
const chief = JSON.parse(readFileSync(join(REPO_ROLES, 'chief-of-staff.json'), 'utf8'));
|
||||
writeJson(join(dir, 'chief-of-staff.json'), { ...chief, hireable: ['coder'] });
|
||||
copyFileSync(join(REPO_ROLES, 'chief-of-staff.md'), join(dir, 'chief-of-staff.md'));
|
||||
writeJson(join(dir, 'coder.json'), '{not json');
|
||||
assert.throws(() => loadRole(dir, 'chief-of-staff'), refusal('role file coder.json is not valid JSON', { exitCode: 2, paths: [dir] }));
|
||||
});
|
||||
|
||||
test('loadBusiness: a missing business file is named by its checked id', (t) => {
|
||||
const dir = join(under(t), 'config');
|
||||
assert.throws(() => loadBusiness('acme', { dir, rolesDir: REPO_ROLES }), refusal('business file acme.json not found', { exitCode: 4, paths: [dir] }));
|
||||
});
|
||||
|
||||
test('loadBusiness: a symbolic link business file is named by its checked id', (t) => {
|
||||
const root = under(t), dir = join(root, 'config');
|
||||
writeJson(join(root, 'real.json'), {});
|
||||
mkdirSync(join(dir, 'businesses'), { recursive: true });
|
||||
symlinkSync(join(root, 'real.json'), join(dir, 'businesses', 'acme.json'));
|
||||
assert.throws(() => loadBusiness('acme', { dir, rolesDir: REPO_ROLES }), refusal('business file acme.json must be a regular, non-symbolic-link file', { exitCode: 4, paths: [dir] }));
|
||||
});
|
||||
|
||||
test('deleteOrg: a failed deletion names the generated receipt folder under the data root, never the host path', async (t) => {
|
||||
const root = under(t);
|
||||
const ctx = { configDir: join(root, 'config'), dataRoot: join(root, 'data'), instance: 'test',
|
||||
principal: { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' } };
|
||||
for (const dir of [ctx.configDir, ctx.dataRoot]) mkdirSync(dir, { mode: 0o700 });
|
||||
await createOrg(ctx, 'alpha', await templateRender('alpha'));
|
||||
const folder = 'alpha-2026-11-10T00-00-00-000Z-0123abcd';
|
||||
const registryFile = join(ctx.configDir, 'org-runtime.json');
|
||||
const registry = JSON.parse(readFileSync(registryFile));
|
||||
registry.orgs.alpha.state = 'deleting';
|
||||
registry.orgs.alpha.deletion = { actor: ctx.principal, time: new Date().toISOString(), revision: 1, folder };
|
||||
writeJson(registryFile, registry);
|
||||
const destination = join(ctx.dataRoot, 'deleted', folder);
|
||||
mkdirSync(destination, { recursive: true, mode: 0o700 });
|
||||
writeJson(join(destination, 'receipt.json'), '{not json');
|
||||
chmodSync(join(destination, 'receipt.json'), 0o600);
|
||||
await assert.rejects(deleteOrg(ctx, 'alpha', { confirm: 'alpha', ifRevision: 1, busStop: async () => assert.fail('must refuse first') }),
|
||||
refusal(`deletion receipt file is not valid JSON; deletion remains in progress. Inspect deleted/${folder}/receipt.json under the data root and rerun mosaic org delete alpha --confirm alpha`,
|
||||
{ name: 'SyntaxError', paths: [ctx.dataRoot, destination] }));
|
||||
});
|
||||
|
||||
// Review round (org-redact5 Sol P2 #1): a raw filesystem error from the organization
|
||||
// service names the operation, the file by label and the error code, never the host path.
|
||||
async function deletingOrg(t) {
|
||||
const root = under(t);
|
||||
const ctx = { configDir: join(root, 'config'), dataRoot: join(root, 'data'), instance: 'test',
|
||||
principal: { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' } };
|
||||
for (const dir of [ctx.configDir, ctx.dataRoot]) mkdirSync(dir, { mode: 0o700 });
|
||||
await createOrg(ctx, 'alpha', await templateRender('alpha'));
|
||||
return { root, ctx };
|
||||
}
|
||||
|
||||
test('deleteOrg: a receipt that is a symbolic link is refused by label and error code, never by host path', async (t) => {
|
||||
const { root, ctx } = await deletingOrg(t);
|
||||
const folder = 'alpha-2026-11-10T00-00-00-000Z-0123abcd';
|
||||
const registryFile = join(ctx.configDir, 'org-runtime.json');
|
||||
const registry = JSON.parse(readFileSync(registryFile));
|
||||
registry.orgs.alpha.state = 'deleting';
|
||||
registry.orgs.alpha.deletion = { actor: ctx.principal, time: new Date().toISOString(), revision: 1, folder };
|
||||
writeJson(registryFile, registry);
|
||||
const destination = join(ctx.dataRoot, 'deleted', folder);
|
||||
mkdirSync(destination, { recursive: true, mode: 0o700 });
|
||||
writeJson(join(root, 'elsewhere.json'), {});
|
||||
symlinkSync(join(root, 'elsewhere.json'), join(destination, 'receipt.json'));
|
||||
await assert.rejects(deleteOrg(ctx, 'alpha', { confirm: 'alpha', ifRevision: 1, busStop: async () => assert.fail('must refuse first') }), (error) => {
|
||||
assert.equal(error.code, 'ELOOP');
|
||||
assert.equal(error.syscall, 'open');
|
||||
assert.equal(error.deleteStep, 'prepare');
|
||||
return refusal(`cannot open deletion receipt file (ELOOP); deletion remains in progress. Inspect deleted/${folder}/receipt.json under the data root and rerun mosaic org delete alpha --confirm alpha`,
|
||||
{ name: 'Error', paths: [ctx.dataRoot, destination, root] })(error);
|
||||
});
|
||||
});
|
||||
|
||||
test('notificationShow: a missing notifier directory is refused by label and error code, never by host path', async (t) => {
|
||||
const { root, ctx } = await deletingOrg(t);
|
||||
const notify = join(ctx.dataRoot, 'org-runtime', 'alpha', 'notify');
|
||||
renameSync(notify, `${notify}.gone`);
|
||||
await assert.rejects(notificationShow(ctx, 'alpha'), (error) => {
|
||||
assert.equal(error.code, 'ENOENT');
|
||||
return refusal('cannot lstat notifier directory (ENOENT)', { name: 'Error', paths: [ctx.dataRoot, notify, root] })(error);
|
||||
});
|
||||
});
|
||||
|
||||
// Sol P3 #4: a refused command given by absolute path is named by the basename refusedCommand matched.
|
||||
test('checkCommands: a refused interpreter by absolute path is named by its basename, never by host path', (t) => {
|
||||
const bin = join(under(t), 'bin');
|
||||
assert.throws(() => checkCommands([[join(bin, 'bash'), '-c']], 'grant.commands'),
|
||||
refusal('grant.commands[0] starts with bash, a shell, runner or interpreter, which would grant any command', { exitCode: 2, paths: [bin] }));
|
||||
});
|
||||
|
||||
// Sol P2 #3: the system config refusal names where the config came from, never its path.
|
||||
test('config validate: a missing system config is named by where it came from, never by host path', (t) => {
|
||||
const root = under(t), config = join(root, 'config.json');
|
||||
const err = { text: '' }, out = { text: '' };
|
||||
const streams = { stdout: { write: (s) => { out.text += s; return true; } }, stderr: { write: (s) => { err.text += s; return true; } } };
|
||||
assert.equal(runConfigCommand(['validate', 'acme'], { ...streams, env: { PATH: process.env.PATH, HOME: root, MOSAIC_CONFIG: config } }), 3);
|
||||
assert.equal(out.text, '');
|
||||
assert.match(err.text, new RegExp(`^${BUSINESS_CONFIG.prefix}: system config problem \\(mosaic-config exit [1-9][0-9]*\\): .*the system config MOSAIC_CONFIG names\n$`));
|
||||
assert.ok(!err.text.includes(SENTINEL) && !err.text.includes(config), err.text);
|
||||
});
|
||||
@@ -505,3 +505,92 @@ A harness declares `outputPolicy({ proxy })` as `adapter`, `non-newline-stdout`,
|
||||
from module directories, and malformed modules refuse discovery. An organization
|
||||
selects modules with `plugins.system-type`; an omitted list enables none. The
|
||||
initial five modules declare no toolsets yet.
|
||||
|
||||
### Instance-installed packages (PL1, #1873)
|
||||
|
||||
The instance owner may install trusted plugin code from the host shell:
|
||||
|
||||
```
|
||||
mosaic plugins install ./package --sha256 <64 lowercase hex>
|
||||
mosaic plugins install ./package.zip --sha256 <64 lowercase hex>
|
||||
mosaic plugins install https://example.invalid/plugin.git --commit <40 lowercase hex> --sha256 <64 lowercase hex>
|
||||
mosaic plugins list
|
||||
mosaic plugins remove <name>
|
||||
```
|
||||
|
||||
These commands read the selected system config's `dataRoot`, use
|
||||
`<dataRoot>/plugins/<name>`, and publish `<dataRoot>/plugins.lock.json`.
|
||||
They have no bus, Console, organization, tester, or agent installation path.
|
||||
Removal holds the organization registry lock and refuses if any organization
|
||||
file still enables the package. Disable it through the existing owner-only
|
||||
`plugins.set` before removal. The installer prints the names of all host tools
|
||||
before its first instance write. Plugin code runs as the instance uid; installing
|
||||
it is a trust decision, and a hash pin does not make code safe.
|
||||
|
||||
Every package has this strict `plugin.json` shape (all fields required):
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "example",
|
||||
"version": "1.0.0",
|
||||
"kinds": ["usage-source"],
|
||||
"interfaceVersions": {"usage-source": 1},
|
||||
"sha256": "<64 lowercase hex>",
|
||||
"source": {"path": "./source", "version": "1.0.0"},
|
||||
"signature": null,
|
||||
"tools": [{
|
||||
"name": "example.lookup",
|
||||
"description": "Look up a local fixture",
|
||||
"arguments": {"type": "object", "properties": {}, "required": [], "additionalProperties": false},
|
||||
"runs": "sandbox",
|
||||
"entry": "tools/lookup.mjs"
|
||||
}],
|
||||
"actions": ["example.publish"],
|
||||
"services": [{"name": "example.account", "scopes": ["read"]}],
|
||||
"roleTemplates": [{"name": "example.writer", "file": "templates/writer.json"}]
|
||||
}
|
||||
```
|
||||
|
||||
`source` takes exactly one of `path`/`url` and one of `version`/`commit`.
|
||||
Git installs record their actual full commit and URL in the lock. `signature`
|
||||
is reserved and must be null. Kinds and interface versions remain core policy;
|
||||
each kind has `<kind>/index.mjs` with the usual exports. Every declaration has
|
||||
the plugin namespace, and no declaration may reuse a core name or hard wait.
|
||||
Templates are recorded as data; installation imports no role and hires nobody.
|
||||
|
||||
`pluginHash(directory)` in `src/pinned.mjs` computes the expected digest. Sort
|
||||
relative regular-file paths lexically and hash the UTF-8 frame
|
||||
`<path byte length>:<path>:<file byte length>:` followed by each file's bytes.
|
||||
For `plugin.json`, replace only the top-level `sha256` value with 64 zeroes.
|
||||
All other bytes, including whitespace and descriptions, are pinned. Symlinks,
|
||||
hard links, special files, unsafe archive paths and duplicate archive entries
|
||||
refuse. Files become 0444, directories 0555. Empty directories carry no code
|
||||
and are omitted from the digest.
|
||||
|
||||
A declared tool exports `execute(arguments)` from its `entry` module. Argument
|
||||
schemas use the bounded, reference-free subset checked in `src/manifest.mjs`;
|
||||
unknown keywords refuse. Tool presence follows the resolved role, and the
|
||||
broker rechecks the current organization setting and hash on every call.
|
||||
Host tools run in the host process. Sandbox tools run in a fresh bubblewrap
|
||||
namespace with the pinned package and Node runtime, no home, config, workspace
|
||||
or network. They receive JSON on stdin and return JSON on stdout.
|
||||
|
||||
The loader checks the complete package against the lock before every installed
|
||||
module import. A refusal logs static codes and the validated package name;
|
||||
core modules continue. A bad pin evicts previously loaded installed modules.
|
||||
Once a package path has been imported, the process latches its verified pin before
|
||||
the first import attempt (including failed attempts). Removing and reinstalling a
|
||||
different pin at that path refuses with `plugin-restart-required` until the host
|
||||
restarts. The latch is shared by loaders and host tools, so cached helper modules
|
||||
cannot run under a replacement pin. A fresh sandbox tool process has no old cache.
|
||||
|
||||
Executable dependencies must be bundled in the package and imported by relative
|
||||
path (or an in-package file URL). A shared resolver guard refuses bare third-party
|
||||
lookups, executable data URLs and paths outside that package; Node built-ins remain
|
||||
available. This applies to static imports, dynamic imports and `createRequire`,
|
||||
including sandbox tools. It constrains module resolution, not R10 host authority:
|
||||
trusted host code can still use filesystem and other host APIs.
|
||||
|
||||
A role referencing a missing or disabled plugin refuses at launch with
|
||||
`plugin-disabled` and the package name. Disabling never fires or rewrites an
|
||||
agent; its next plugin tool call refuses with that same code.
|
||||
@@ -8,10 +8,10 @@ const report = entry => process.stderr.write(`channel: ${JSON.stringify({
|
||||
event: entry.event, kind: entry.kind, name: entry.name, code: entry.code, kept: entry.kept,
|
||||
})}\n`);
|
||||
|
||||
export function createChannelCaller({ name, call, log = report }) {
|
||||
export function createChannelCaller({ name, call, log = report, dataRoot = null }) {
|
||||
if (typeof name !== "string" || !NAME.test(name)) throw new PluginError("bad-name", "invalid channel name");
|
||||
if (typeof call !== "function") throw new TypeError("channel needs a core caller");
|
||||
const loader = createLoader({ log });
|
||||
const loader = createLoader({ log, dataRoot });
|
||||
return async (verb, args = {}) => {
|
||||
const settings = await call("plugins", {});
|
||||
if (!record(settings) || !record(settings.plugins)) {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// PL1: executable dependencies must be bundled inside the pinned package.
|
||||
// This constrains module resolution, not the host authority granted by R10.
|
||||
// Kept standalone so a sandbox tool can use this same guard in its fresh process.
|
||||
import { isBuiltin, registerHooks } from 'node:module';
|
||||
import { isAbsolute, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const roots = new Set();
|
||||
let registered = false;
|
||||
const inside = (root, path) => {
|
||||
const rel = relative(root, path);
|
||||
return rel !== '..' && !rel.startsWith('../') && !isAbsolute(rel);
|
||||
};
|
||||
const refused = () => { throw new Error('plugin import refused'); };
|
||||
export function guardPluginImports(directory) {
|
||||
roots.add(resolve(directory));
|
||||
if (registered) return;
|
||||
registerHooks({resolve(specifier, context, nextResolve) {
|
||||
let parent;
|
||||
try { if (context.parentURL?.startsWith('file:')) parent = fileURLToPath(context.parentURL); }
|
||||
catch { return refused(); }
|
||||
const root = parent && [...roots].find(dir => inside(dir, parent));
|
||||
if (!root || isBuiltin(specifier)) return nextResolve(specifier, context);
|
||||
// Refuse bare lookups before Node searches unpinned ancestor node_modules.
|
||||
if (!specifier.startsWith('./') && !specifier.startsWith('../') && !specifier.startsWith('/') && !specifier.startsWith('file:')) return refused();
|
||||
try {
|
||||
const candidate = new URL(specifier, context.parentURL);
|
||||
if (candidate.protocol !== 'file:' || !inside(root, fileURLToPath(candidate))) return refused();
|
||||
const result = nextResolve(specifier, context);
|
||||
if (!result.url.startsWith('file:') || !inside(root, fileURLToPath(result.url))) return refused();
|
||||
return result;
|
||||
} catch { return refused(); }
|
||||
}});
|
||||
registered = true;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Instance-uid, host-only management. No broker verb or organization writer calls this module.
|
||||
import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { bad, HASH } from './manifest.mjs';
|
||||
import { KINDS } from './kinds.mjs';
|
||||
import { hashFiles, pluginFiles, pluginPaths, readPlugin, readPluginLock, safeDirectory } from './pinned.mjs';
|
||||
const run = (cmd,args,options={}) => {
|
||||
const r=spawnSync(cmd,args,{encoding:'utf8',maxBuffer:40*1024*1024,timeout:120000,...options});
|
||||
if(r.status!==0 || r.error) bad('source-refused'); return r.stdout;
|
||||
};
|
||||
// Validate the complete archive before creating a file. Never extract links, devices,
|
||||
// duplicate names, absolute paths, dot components, or an archive's permission bits.
|
||||
const EXTRACT = `import sys,zipfile,tarfile,os,stat,io
|
||||
kind,src,dst=sys.argv[1:]
|
||||
archive=zipfile.ZipFile(src) if kind=='zip' else tarfile.open(src)
|
||||
rows=[]; seen=set(); total=0
|
||||
for e in archive.infolist() if kind=='zip' else archive.getmembers():
|
||||
n=e.filename if kind=='zip' else e.name
|
||||
directory=e.is_dir() if kind=='zip' else e.isdir()
|
||||
n=n.rstrip('/') if directory else n
|
||||
if not n or n.startswith('/') or any(p in ('','..','.') for p in n.split('/')) or '\\\\' in n: raise ValueError('path')
|
||||
if n in seen: raise ValueError('duplicate')
|
||||
seen.add(n)
|
||||
mode=e.external_attr>>16 if kind=='zip' else e.mode
|
||||
if kind=='zip':
|
||||
if stat.S_IFMT(mode) not in (0,stat.S_IFREG,stat.S_IFDIR): raise ValueError('type')
|
||||
elif not (e.isfile() or e.isdir()): raise ValueError('type')
|
||||
size=e.file_size if kind=='zip' else e.size
|
||||
total+=size
|
||||
if total>33554432 or len(seen)>2048: raise ValueError('size')
|
||||
rows.append((e,n,directory))
|
||||
for e,n,directory in rows:
|
||||
p=os.path.join(dst,n)
|
||||
if directory: os.makedirs(p,exist_ok=True)
|
||||
else:
|
||||
os.makedirs(os.path.dirname(p),exist_ok=True)
|
||||
with open(p,'xb') as out: out.write(archive.read(e) if kind=='zip' else archive.extractfile(e).read())
|
||||
`;
|
||||
function sourceFiles(source,commit,scratch) {
|
||||
if(commit!==undefined) {
|
||||
if(!/^[a-f0-9]{40}$/.test(commit) || typeof source!=='string' || source.startsWith('-') || /[\x00-\x1f]/.test(source)) bad('commit-required');
|
||||
const repo=join(scratch,'repo'),archive=join(scratch,'source.tar'),out=join(scratch,'out'); mkdirSync(out);
|
||||
run('git',['-c','core.hooksPath=/dev/null','clone','--no-checkout','--no-local','--',source,repo]);
|
||||
const actual=run('git',['-C',repo,'rev-parse','--verify',`${commit}^{commit}`]).trim(); if(actual!==commit) bad('source-refused');
|
||||
run('git',['-C',repo,'archive','--format=tar',`--output=${archive}`,commit]);
|
||||
run('python3',['-c',EXTRACT,'tar',archive,out]); return pluginFiles(out);
|
||||
}
|
||||
if(typeof source!=='string' || /^(https?:|ssh:|git:|git@)/.test(source)) bad('commit-required');
|
||||
const st=lstatSync(source); if(st.isSymbolicLink()) bad('unsafe-path');
|
||||
if(st.isDirectory()) return pluginFiles(source);
|
||||
if(!st.isFile() || !source.endsWith('.zip')) bad('source-refused');
|
||||
const out=join(scratch,'out');mkdirSync(out);run('python3',['-c',EXTRACT,'zip',source,out]); return pluginFiles(out);
|
||||
}
|
||||
function writableRemove(dir) {
|
||||
if(!existsSync(dir))return;
|
||||
for(const [p] of pluginFiles(dir)) chmodSync(join(dir,p),0o600);
|
||||
const dirs=new Set(['']);for(const [p] of pluginFiles(dir)) {let parts=p.split('/');parts.pop();while(parts.length){dirs.add(parts.join('/'));parts.pop();}}
|
||||
for(const p of dirs)chmodSync(join(dir,p),0o700);
|
||||
rmSync(dir,{recursive:true});
|
||||
}
|
||||
function withInstallLock(dataRoot,fn) {
|
||||
safeDirectory(dataRoot);const st=lstatSync(dataRoot);if(st.uid!==process.getuid() || st.mode&0o022)bad("plugin-root");const paths=pluginPaths(dataRoot);let fd;
|
||||
try {fd=openSync(paths.mutex,'wx',0o600);}catch{bad('plugin-locked');}
|
||||
try{return fn(paths);}finally{closeSync(fd);unlinkSync(paths.mutex);}
|
||||
}
|
||||
function writeLock(lock,doc) {
|
||||
const temp=`${lock}.new`; try {writeFileSync(temp,JSON.stringify(doc,null,2)+'\n',{flag:'wx',mode:0o600});renameSync(temp,lock);}finally{if(existsSync(temp))unlinkSync(temp);}
|
||||
}
|
||||
export function installPlugin({dataRoot,source,commit,expectedHash,print=()=>{}}) {
|
||||
if(!HASH.test(expectedHash))bad('expected-hash-required');
|
||||
const scratch=mkdtempSync(join(tmpdir(),'pl1-'));
|
||||
try {
|
||||
const files=sourceFiles(source,commit,scratch),candidate=join(scratch,'candidate');mkdirSync(candidate);
|
||||
for(const [p,bytes]of files){mkdirSync(join(candidate,p,'..'),{recursive:true});writeFileSync(join(candidate,p),bytes,{flag:'wx',mode:0o600});}
|
||||
const {manifest}=readPlugin(candidate);
|
||||
if(hashFiles(files)!==expectedHash || manifest.sha256!==expectedHash)bad('hash-mismatch',manifest.name);
|
||||
for(const kind of Object.keys(KINDS)) if (existsSync(new URL(`../${kind}/${manifest.name}/index.mjs`,import.meta.url))) bad('core-name');
|
||||
// This is before even the instance lock is created. Only validated names are printed.
|
||||
for(const tool of manifest.tools.filter(t=>t.runs==='host'))print(`host tool: ${tool.name}`);
|
||||
return withInstallLock(dataRoot,({root,lock})=>{
|
||||
const doc=readPluginLock(dataRoot);if(Object.hasOwn(doc.plugins,manifest.name))bad('already-installed',manifest.name);
|
||||
if(!existsSync(root))mkdirSync(root,{mode:0o700});safeDirectory(root);
|
||||
const dest=join(root,manifest.name);if(existsSync(dest))bad('already-installed',manifest.name);
|
||||
const stage=mkdtempSync(join(root,'.install-'));
|
||||
try {
|
||||
for(const[p,bytes]of files){mkdirSync(join(stage,p,'..'),{recursive:true,mode:0o755});writeFileSync(join(stage,p),bytes,{flag:'wx',mode:0o444});}
|
||||
const dirs=new Set(['']);for(const[p]of files){let parts=p.split('/');parts.pop();while(parts.length){dirs.add(parts.join('/'));parts.pop();}}
|
||||
for(const p of dirs)chmodSync(join(stage,p),0o555);
|
||||
readPlugin(stage);renameSync(stage,dest);
|
||||
doc.plugins[manifest.name]={version:manifest.version,source:commit === undefined ? manifest.source : {url:source,commit},sha256:expectedHash};
|
||||
try{writeLock(lock,doc);}catch(e){writableRemove(dest);throw e;}
|
||||
return {name:manifest.name,...doc.plugins[manifest.name]};
|
||||
}finally{if(existsSync(stage))writableRemove(stage);}
|
||||
});
|
||||
}catch(e){if(e?.name==='PluginError')throw e;bad('install-refused');}finally{rmSync(scratch,{recursive:true,force:true});}
|
||||
}
|
||||
export function removePlugin({dataRoot,name,enabled}) {
|
||||
if(typeof enabled!=='function')bad('organization-state-required');
|
||||
return withInstallLock(dataRoot,({root,lock})=>{
|
||||
const doc=readPluginLock(dataRoot);if(!Object.hasOwn(doc.plugins,name))bad('missing-lock');
|
||||
if(enabled(name))bad('plugin-enabled',name);
|
||||
// Remove the lock first: an interrupted removal can never leave unpinned code loadable.
|
||||
delete doc.plugins[name];writeLock(lock,doc);writableRemove(join(root,name));return {removed:name};
|
||||
});
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
// The plug-in loader (REQ-PLUG-1). It switches on the modules the
|
||||
// organization file's `plugins` object names, one key per kind, and loads
|
||||
// only packages/plugins/<kind>/<name>/index.mjs from the tree this file runs
|
||||
// from. It takes no path from config and loads nothing outside that tree
|
||||
// (lead decision 143). A loaded module runs with the host's authority.
|
||||
// core packages/plugins/<kind>/<name>/index.mjs from its own tree, plus
|
||||
// hash-pinned installed modules from <instance dataRoot>/plugins only. PL1
|
||||
// (#1873), owner-authorized amendment of lead decision 143. No arbitrary
|
||||
// config path is accepted. A loaded module runs with the host's authority.
|
||||
//
|
||||
// A refusal keeps the kind's last good modules and is logged. A kind refused
|
||||
// A core refusal keeps the kind's last good modules and is logged. A refused
|
||||
// installed plugin is omitted; valid core modules in its kind still load. A kind refused
|
||||
// on its first load has nothing switched on. `loaded()` reports what each
|
||||
// kind actually has switched on.
|
||||
import { lstatSync, readdirSync, realpathSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { KINDS, NAME, PluginError, namesFor } from "./kinds.mjs";
|
||||
import { verifiedPlugin, readPluginLock, claimPluginImport } from "./pinned.mjs";
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
@@ -38,7 +41,8 @@ function component(path, directory) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadModule(spec, name, barrier) {
|
||||
async function loadModule(spec, name, barrier, dataRoot = null) {
|
||||
if (dataRoot !== null && !hasModule(spec.kind, name)) return loadPinnedModule(spec, name, barrier, dataRoot);
|
||||
const kindDir = join(ROOT, spec.kind);
|
||||
const nameDir = join(kindDir, name);
|
||||
const index = join(nameDir, "index.mjs");
|
||||
@@ -74,7 +78,10 @@ async function loadModule(spec, name, barrier) {
|
||||
// True when packages/plugins/<kind>/<name>/index.mjs exists in this tree as a real file, with no
|
||||
// symlink on the way down. The setters use it to refuse a name no module answers to before it is
|
||||
// written; the loader's own checks still run at load.
|
||||
export function hasModule(kind, name) {
|
||||
export function hasModule(kind, name, dataRoot = null) {
|
||||
if (dataRoot !== null && !hasModule(kind, name)) {
|
||||
try { verifiedPlugin(dataRoot, name, kind); return true; } catch { return false; }
|
||||
}
|
||||
try {
|
||||
if (!Object.hasOwn(KINDS, kind) || !NAME.test(name)) return false;
|
||||
for (const dir of [ROOT, join(ROOT, kind), join(ROOT, kind, name)]) component(dir, true);
|
||||
@@ -113,7 +120,7 @@ export function discoverModules(kind) {
|
||||
// (loadModule, unchanged). One refused module is reported and never hides the others, and nothing
|
||||
// here changes what createLoader has switched on. Node caches an import by path, so a module
|
||||
// whose file changed is read again only after a restart; a removed directory is gone at once.
|
||||
export async function loadInstalled(kind, { barrier = () => {} } = {}) {
|
||||
export async function loadInstalled(kind, { barrier = () => {}, dataRoot = null } = {}) {
|
||||
if (!Object.hasOwn(KINDS, kind)) refuse("unknown-kind", `unknown kind ${JSON.stringify(kind)}`);
|
||||
const spec = KINDS[kind];
|
||||
const modules = [], refused = [];
|
||||
@@ -129,9 +136,14 @@ export async function loadInstalled(kind, { barrier = () => {} } = {}) {
|
||||
return { modules, refused };
|
||||
}
|
||||
for (const name of found.invalid) report(name, new PluginError("bad-name", `${kind}/${name} doesn't match ${NAME}`, name));
|
||||
if (dataRoot !== null) {
|
||||
try { for (const name of Object.keys(readPluginLock(dataRoot).plugins)) {
|
||||
try { if (verifiedPlugin(dataRoot, name).manifest.kinds.includes(kind) && !found.names.includes(name)) found.names.push(name); } catch(e) { report(name,e); }
|
||||
} } catch(e) { report(null,e); }
|
||||
}
|
||||
for (const name of found.names) {
|
||||
try {
|
||||
modules.push(await loadModule(spec, name, barrier));
|
||||
modules.push(await loadModule(spec, name, barrier, dataRoot));
|
||||
} catch (e) {
|
||||
report(name, e);
|
||||
}
|
||||
@@ -139,7 +151,7 @@ export async function loadInstalled(kind, { barrier = () => {} } = {}) {
|
||||
return { modules, refused };
|
||||
}
|
||||
|
||||
export function createLoader({ log, barrier = () => {} }) {
|
||||
export function createLoader({ log, barrier = () => {}, dataRoot = null }) {
|
||||
if (typeof log !== "function") throw new TypeError("createLoader needs a log callback");
|
||||
const current = new Map(Object.keys(KINDS).map(kind => [kind, Object.freeze([])]));
|
||||
let queue = Promise.resolve();
|
||||
@@ -151,6 +163,14 @@ export function createLoader({ log, barrier = () => {} }) {
|
||||
event: "plugin-refused", kind, name: e.plugin ?? name, code: e.code, message: e.message,
|
||||
kept: kind === null ? loaded() : current.has(kind) ? current.get(kind).map(m => m.name) : [],
|
||||
};
|
||||
if (dataRoot !== null) {
|
||||
const affected=kind===null ? [...current.keys()] : current.has(kind) ? [kind] : [];
|
||||
for(const checkedKind of affected)current.set(checkedKind, Object.freeze(current.get(checkedKind).filter(m => {
|
||||
if (!m.installed) return true;
|
||||
try { verifiedPlugin(dataRoot,m.name,checkedKind); return true; } catch { return false; }
|
||||
})));
|
||||
entry.kept = kind===null ? loaded() : current.has(kind) ? current.get(kind).map(m=>m.name) : [];
|
||||
}
|
||||
log(entry);
|
||||
report.push(entry);
|
||||
}
|
||||
@@ -180,9 +200,10 @@ export function createLoader({ log, barrier = () => {} }) {
|
||||
let ok = true;
|
||||
for (const name of names) {
|
||||
try {
|
||||
next.push(await loadModule(spec, name, barrier));
|
||||
next.push(await loadModule(spec, name, barrier, dataRoot));
|
||||
} catch (e) {
|
||||
refused(kind, name, e, report);
|
||||
if (dataRoot !== null && !hasModule(kind,name)) continue;
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
@@ -219,3 +240,19 @@ export function createLoader({ log, barrier = () => {} }) {
|
||||
|
||||
// Shared pure plug-in setting classification; no check implementation is loaded.
|
||||
export { isDailyModelQuota } from "./model-quota.mjs";
|
||||
|
||||
// Verify before every import, including cache hits. Recheck after the test barrier and import.
|
||||
async function loadPinnedModule(spec, name, barrier, dataRoot) {
|
||||
let checked = verifiedPlugin(dataRoot,name,spec.kind);
|
||||
barrier("checked", {kind:spec.kind,name});
|
||||
checked = verifiedPlugin(dataRoot,name,spec.kind);
|
||||
const entry = join(checked.dir,spec.kind,"index.mjs");
|
||||
claimPluginImport(checked);
|
||||
let module;
|
||||
try { module=await import(pathToFileURL(entry).href + '?pin=' + checked.manifest.sha256); }
|
||||
catch { refuse("load-failed","installed plugin load refused",name); }
|
||||
verifiedPlugin(dataRoot,name,spec.kind);
|
||||
for(const key of spec.exports) if(typeof module[key]!=="function") refuse("missing-export","installed plugin export refused",name);
|
||||
if(module.interfaceVersion!==spec.interfaceVersion) refuse("interface-version","installed plugin interface refused",name);
|
||||
return Object.freeze({name,module,installed:true});
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// PL1 (#1873): strict, non-executing manifest validation. All errors are static;
|
||||
// neither a malformed field nor plugin free text is ever included in a refusal.
|
||||
import { KINDS, NAME, PluginError } from './kinds.mjs';
|
||||
import { buildRegistry } from '../../organization/src/composition.mjs';
|
||||
|
||||
export const HASH = /^[a-f0-9]{64}$/;
|
||||
const object = v => v !== null && typeof v === 'object' && !Array.isArray(v);
|
||||
export const bad = (code = 'invalid-manifest', name = null) => { throw new PluginError(code, `plugin refused: ${code}`, name); };
|
||||
const keys = (v, allowed, required = allowed) => {
|
||||
if (!object(v) || Object.keys(v).some(k => !allowed.includes(k)) || required.some(k => !Object.hasOwn(v, k))) bad();
|
||||
};
|
||||
const text = (v, max = 2048) => { if (typeof v !== 'string' || !v.length || v.length > max || /[\x00-\x1f]/.test(v)) bad(); };
|
||||
export function pluginPath(v) {
|
||||
if (typeof v !== 'string' || v.length > 200 || !/^[a-zA-Z0-9._/-]+$/.test(v) || v.split('/').some(p => !p || p === '.' || p === '..') || v.startsWith('/')) bad('unsafe-path');
|
||||
return v;
|
||||
}
|
||||
// Deliberately bounded JSON Schema subset. No remote references, executable validators,
|
||||
// or unknown keywords. The same schema is enforced before dispatch, not just shown to a model.
|
||||
export function checkSchema(s, depth = 0) {
|
||||
keys(s, ['type', 'description', 'properties', 'required', 'additionalProperties', 'items', 'enum', 'minLength', 'maxLength', 'minimum', 'maximum', 'minItems', 'maxItems'], ['type']);
|
||||
if (depth > 12 || !['object', 'array', 'string', 'number', 'integer', 'boolean', 'null'].includes(s.type)) bad();
|
||||
if (s.description !== undefined) text(s.description);
|
||||
if (s.enum !== undefined && (!Array.isArray(s.enum) || !s.enum.length || s.enum.some(v => v !== null && !['string','number','boolean'].includes(typeof v)))) bad();
|
||||
for (const k of ['minLength','maxLength','minItems','maxItems']) if (s[k] !== undefined && (!Number.isSafeInteger(s[k]) || s[k] < 0)) bad();
|
||||
for (const k of ['minimum','maximum']) if (s[k] !== undefined && !Number.isFinite(s[k])) bad();
|
||||
if (s.type === 'object') {
|
||||
if (!object(s.properties) || s.additionalProperties !== false || !Array.isArray(s.required) || new Set(s.required).size !== s.required.length || s.required.some(k => typeof k !== 'string' || !Object.hasOwn(s.properties,k))) bad();
|
||||
for (const [k,v] of Object.entries(s.properties)) { if (!/^[a-zA-Z][a-zA-Z0-9_]{0,63}$/.test(k)) bad(); checkSchema(v, depth+1); }
|
||||
} else if (s.properties !== undefined || s.required !== undefined || s.additionalProperties !== undefined) bad();
|
||||
if (s.type === 'array') checkSchema(s.items, depth+1);
|
||||
else if (s.items !== undefined) bad();
|
||||
return s;
|
||||
}
|
||||
export function validArguments(v, s) {
|
||||
if (s.enum && !s.enum.some(x => x === v)) return false;
|
||||
if (s.type === 'object') return object(v) && Object.keys(v).every(k => Object.hasOwn(s.properties,k) && validArguments(v[k],s.properties[k])) && s.required.every(k => Object.hasOwn(v,k));
|
||||
if (s.type === 'array') return Array.isArray(v) && v.length >= (s.minItems ?? 0) && v.length <= (s.maxItems ?? 10000) && v.every(x => validArguments(x,s.items));
|
||||
if (s.type === 'null') return v === null;
|
||||
if (s.type === 'integer' ? !Number.isSafeInteger(v) : typeof v !== s.type) return false;
|
||||
if (typeof v === 'string') return v.length >= (s.minLength ?? 0) && v.length <= (s.maxLength ?? 1000000);
|
||||
if (typeof v === 'number') return Number.isFinite(v) && v >= (s.minimum ?? -Infinity) && v <= (s.maximum ?? Infinity);
|
||||
return true;
|
||||
}
|
||||
export function declaredSet(m) {
|
||||
const bare = n => n.slice(m.name.length + 1);
|
||||
return { name:m.name, roleTemplates:m.roleTemplates.map(t=>({name:bare(t.name),file:t.file})), tools:m.tools.map(t => bare(t.name)), actions:m.actions.map(bare), services:m.services.map(s => ({name:bare(s.name),scopes:s.scopes})) };
|
||||
}
|
||||
export function checkManifest(m) {
|
||||
keys(m, ['name','version','kinds','interfaceVersions','sha256','source','signature','tools','actions','services','roleTemplates']);
|
||||
if (typeof m.name !== 'string' || !NAME.test(m.name) || m.name.length > 32) bad();
|
||||
if (typeof m.version !== 'string' || !/^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/.test(m.version) || !HASH.test(m.sha256)) bad();
|
||||
if (!Array.isArray(m.kinds) || !m.kinds.length || new Set(m.kinds).size !== m.kinds.length || m.kinds.some(k => !Object.hasOwn(KINDS,k))) bad('unknown-kind');
|
||||
keys(m.interfaceVersions,m.kinds);
|
||||
for (const k of m.kinds) if (m.interfaceVersions[k] !== KINDS[k].interfaceVersion) bad('interface-version');
|
||||
keys(m.source,['url','path','commit','version'],[]);
|
||||
if (Object.hasOwn(m.source,'url') === Object.hasOwn(m.source,'path') || Object.hasOwn(m.source,'commit') === Object.hasOwn(m.source,'version')) bad();
|
||||
for (const v of Object.values(m.source)) text(v);
|
||||
if (m.source.commit !== undefined && !/^[a-f0-9]{40}$/.test(m.source.commit)) bad();
|
||||
if (m.signature !== null) bad(); // reserved, never claims verification
|
||||
for (const k of ['tools','actions','services','roleTemplates']) if (!Array.isArray(m[k]) || m[k].length > 128) bad();
|
||||
const full = n => { if (typeof n !== 'string' || !n.startsWith(`${m.name}.`) || !/^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)+$/.test(n) || n.length > 64) bad(); };
|
||||
for (const t of m.tools) {
|
||||
keys(t,['name','description','arguments','runs','entry']); full(t.name); text(t.description); checkSchema(t.arguments);
|
||||
if (t.arguments.type !== 'object' || !['sandbox','host'].includes(t.runs)) bad(); pluginPath(t.entry);
|
||||
if (!t.entry.endsWith('.mjs')) bad();
|
||||
}
|
||||
for (const a of m.actions) full(a);
|
||||
for (const s of m.services) { keys(s,['name','scopes']); full(s.name); }
|
||||
const seen = new Set([...m.tools.map(t=>t.name),...m.actions,...m.services.map(s=>s.name)]);
|
||||
for (const r of m.roleTemplates) { keys(r,['name','file']); full(r.name); pluginPath(r.file); if (seen.has(r.name)) bad(); seen.add(r.name); }
|
||||
try { buildRegistry({plugins:[declaredSet(m)]}); } catch { bad(); }
|
||||
return structuredClone(m);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Non-executing organization view. Disabled/missing capabilities carry only a validated name.
|
||||
import { buildRegistry } from '../../organization/src/composition.mjs';
|
||||
import { declaredSet, bad } from './manifest.mjs';
|
||||
import { verifiedPlugin } from './pinned.mjs';
|
||||
import { hasModule } from './loader.mjs';
|
||||
export {pluginName} from "../../organization/src/vocabulary.mjs";
|
||||
import {pluginName} from "../../organization/src/vocabulary.mjs";
|
||||
export function organizationPlugins(dataRoot,settings = {}) {
|
||||
const names=new Set(Object.entries(settings).flatMap(([kind,v])=>(Array.isArray(v)?v:[v]).filter(name=>!hasModule(kind,name))));
|
||||
const manifests=[],refused=[];
|
||||
if(dataRoot!==null) for(const name of names){try {const m=verifiedPlugin(dataRoot,name).manifest;
|
||||
if(m.kinds.every(k=>(Array.isArray(settings[k])?settings[k]:[settings[k]]).includes(name)))manifests.push(m);
|
||||
}catch {refused.push(name);}}
|
||||
const registry=buildRegistry({plugins:manifests.map(declaredSet)});
|
||||
return {registry,manifests,refused,roleTemplates:manifests.flatMap(m=>m.roleTemplates)};
|
||||
}
|
||||
export function assertPluginCapabilities(role,view) {
|
||||
const check = (cap,registered) => {
|
||||
const name=pluginName(cap);if(!name)return;
|
||||
if(!view.manifests.some(m=>m.name===name))bad('plugin-disabled',name);
|
||||
if(!registered)bad('invalid-request');
|
||||
};
|
||||
for(const tool of role.tools ?? [])check(tool,view.registry.tools.includes(tool));
|
||||
for(const action of [...(role.authority?.withinRole ?? []),...(role.authority?.crossRole ?? [])])check(action,view.registry.actions.includes(action));
|
||||
const credentials=Array.isArray(role.credentials) ? role.credentials : Object.keys(role.credentials ?? {}).map(service=>({service}));
|
||||
for(const credential of credentials) {
|
||||
const {service,scopes}=credential;check(service,Object.hasOwn(view.registry.services,service));
|
||||
if(pluginName(service) && scopes!==undefined && (!Array.isArray(scopes) || scopes.some(s=>!view.registry.services[service].scopes.includes(s))))bad('invalid-request');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// PL1: only these derived instance paths may supply installed plugin bytes.
|
||||
import { createHash } from 'node:crypto';
|
||||
import { constants, openSync, closeSync, fstatSync, lstatSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { isAbsolute, join, resolve } from 'node:path';
|
||||
import { NAME } from './kinds.mjs';
|
||||
import { bad, checkManifest, HASH } from './manifest.mjs';
|
||||
import { guardPluginImports } from './imports.mjs';
|
||||
// Node caches transitive modules by canonical path. Never attribute old cached
|
||||
// code to a replacement pin, even after a failed import or a new loader instance.
|
||||
const importedPins = new Map();
|
||||
function checkImportedPin(dir, hash, name) {
|
||||
if (importedPins.has(dir) && importedPins.get(dir) !== hash) bad('plugin-restart-required',name);
|
||||
}
|
||||
export function claimPluginImport({dir,manifest}) {
|
||||
checkImportedPin(dir,manifest.sha256,manifest.name);
|
||||
guardPluginImports(dir);
|
||||
importedPins.set(dir,manifest.sha256); // before the first import attempt
|
||||
}
|
||||
export function pluginPaths(dataRoot) {
|
||||
if (typeof dataRoot !== 'string' || !isAbsolute(dataRoot) || resolve(dataRoot) !== dataRoot) bad('plugin-root');
|
||||
return {root:join(dataRoot,'plugins'),lock:join(dataRoot,'plugins.lock.json'),mutex:join(dataRoot,'plugins.install.lock')};
|
||||
}
|
||||
export function safeDirectory(path) {
|
||||
let current = '/';
|
||||
for (const part of resolve(path).split('/').filter(Boolean)) {
|
||||
current = join(current,part); const st = lstatSync(current);
|
||||
if (st.isSymbolicLink() || !st.isDirectory()) bad('unsafe-path');
|
||||
}
|
||||
}
|
||||
function readRegular(path,expected=null) {
|
||||
let fd;try {
|
||||
fd=openSync(path,constants.O_RDONLY|constants.O_NOFOLLOW|constants.O_NONBLOCK);
|
||||
const st=fstatSync(fd);
|
||||
if(!st.isFile() || st.nlink!==1 || (expected && (st.ino!==expected.ino || st.dev!==expected.dev)) || st.size>32*1024*1024)bad('unsafe-path');
|
||||
return readFileSync(fd);
|
||||
}finally{if(fd!==undefined)closeSync(fd);}
|
||||
}
|
||||
export function pluginFiles(dir) {
|
||||
safeDirectory(dir);
|
||||
const files = []; let size = 0;
|
||||
function walk(rel = '') {
|
||||
for (const n of readdirSync(join(dir,rel)).sort()) {
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(n)) bad('unsafe-path');
|
||||
const path = rel ? `${rel}/${n}` : n, st = lstatSync(join(dir,path));
|
||||
if (st.uid!==process.getuid() || st.mode&0o022 || st.isSymbolicLink() || (!st.isDirectory() && !st.isFile()) || st.nlink > (st.isDirectory() ? Infinity : 1)) bad('unsafe-path');
|
||||
if (st.isDirectory()) walk(path);
|
||||
else { size += st.size; if (size > 32*1024*1024 || files.length > 2048) bad('plugin-too-large'); files.push([path,readRegular(join(dir,path),st)]); }
|
||||
}
|
||||
}
|
||||
walk(); return files.sort(([a],[b]) => a < b ? -1 : a > b ? 1 : 0);
|
||||
}
|
||||
// Hash every byte, replacing only the top-level sha256 string with 64 zeroes to
|
||||
// break the manifest's self-reference. Formatting and all other metadata stay pinned.
|
||||
function manifestBytes(bytes) {
|
||||
let text,value;try{text=new TextDecoder('utf-8',{fatal:true,ignoreBOM:true}).decode(bytes);value=JSON.parse(text);}catch{bad();}
|
||||
if(!value || typeof value!=="object" || !HASH.test(value.sha256))bad();
|
||||
let depth=0,found=0,result=text;
|
||||
for(let i=0;i<text.length;i++) {
|
||||
if(text[i]==='{' || text[i]==='['){depth++;continue;}
|
||||
if(text[i]==='}' || text[i]===']'){depth--;continue;}
|
||||
if(text[i]!== '\"')continue;
|
||||
const start=i++;for(;i<text.length;i++){if(text[i]==='\\'){i++;continue;}if(text[i]==='\"')break;}
|
||||
if(depth!==1 || JSON.parse(text.slice(start,i+1))!=="sha256")continue;
|
||||
const rest=/^\s*:\s*"([a-f0-9]{64})"/.exec(text.slice(i+1));
|
||||
if(!rest)continue;
|
||||
const offset=i+1+rest[0].lastIndexOf(rest[1]);result=result.slice(0,offset)+'0'.repeat(64)+result.slice(offset+64);found++;
|
||||
}
|
||||
if(found!==1)bad();return Buffer.from(result);
|
||||
}
|
||||
export function hashFiles(files) {
|
||||
const h=createHash('sha256');
|
||||
for (let [path,bytes] of files) {
|
||||
if(path==='plugin.json')bytes=manifestBytes(bytes);
|
||||
h.update(`${Buffer.byteLength(path)}:${path}:${bytes.length}:`).update(bytes);
|
||||
}
|
||||
return h.digest('hex');
|
||||
}
|
||||
export const pluginHash = dir => hashFiles(pluginFiles(dir));
|
||||
export function readPlugin(dir) {
|
||||
const files=pluginFiles(dir); let manifest;
|
||||
try { manifest=checkManifest(JSON.parse(files.find(([p])=>p==='plugin.json')?.[1])); } catch { bad('invalid-manifest'); }
|
||||
if (hashFiles(files)!==manifest.sha256) bad('hash-mismatch',manifest.name);
|
||||
const names=new Set(files.map(([p])=>p));
|
||||
for (const p of [...manifest.kinds.map(k=>`${k}/index.mjs`),...manifest.tools.map(t=>t.entry),...manifest.roleTemplates.map(t=>t.file)]) if (!names.has(p)) bad('missing-file',manifest.name);
|
||||
return {manifest,files};
|
||||
}
|
||||
export function readPluginLock(dataRoot) {
|
||||
const {lock}=pluginPaths(dataRoot); safeDirectory(dataRoot);
|
||||
const rootStat=lstatSync(dataRoot);if(rootStat.uid!==process.getuid() || rootStat.mode&0o022)bad('plugin-root');
|
||||
let doc;
|
||||
try { const st=lstatSync(lock); if (st.isSymbolicLink() || !st.isFile() || st.uid!==process.getuid() || st.mode&0o022) bad('invalid-lock'); doc=JSON.parse(readRegular(lock,st)); }
|
||||
catch (e) { if(e.code==='ENOENT') return {version:1,plugins:{}}; bad('invalid-lock'); }
|
||||
if (!doc || doc.version!==1 || Object.keys(doc).some(k=>!['version','plugins'].includes(k)) || !doc.plugins || Array.isArray(doc.plugins) || typeof doc.plugins!=='object') bad('invalid-lock');
|
||||
for(const [n,p] of Object.entries(doc.plugins)) if(n.length>32 || !NAME.test(n) || !p || Object.keys(p).sort().join(',')!=='sha256,source,version' || !HASH.test(p.sha256) || typeof p.version!=='string') bad('invalid-lock');
|
||||
return doc;
|
||||
}
|
||||
export function verifiedPlugin(dataRoot,name,kind=null) {
|
||||
if(typeof name!=='string' || name.length>32 || !NAME.test(name)) bad('bad-name');
|
||||
try {
|
||||
const pin=readPluginLock(dataRoot).plugins[name]; if(!pin) bad('missing-lock',name);
|
||||
const {root}=pluginPaths(dataRoot); const dir=join(root,name); const result=readPlugin(dir);
|
||||
if(result.manifest.name!==name || result.manifest.sha256!==pin.sha256 || result.manifest.version!==pin.version) bad('hash-mismatch',name);
|
||||
if(kind && !result.manifest.kinds.includes(kind)) bad('unknown-kind',name);
|
||||
checkImportedPin(dir,result.manifest.sha256,name);
|
||||
return {...result,dir};
|
||||
} catch(e) { bad(['missing-lock','hash-mismatch','unknown-kind','unsafe-path','invalid-lock','plugin-restart-required'].includes(e.code)?e.code:'plugin-unavailable',name); }
|
||||
}
|
||||
@@ -2,8 +2,8 @@ import { loadInstalled } from './loader.mjs';
|
||||
import { NAME } from './kinds.mjs';
|
||||
|
||||
// Discovery is fail-closed: malformed or unloadable installed modules cannot be advertised.
|
||||
export async function installedSystemTypes() {
|
||||
const { modules, refused } = await loadInstalled('system-type');
|
||||
export async function installedSystemTypes({dataRoot = null} = {}) {
|
||||
const { modules, refused } = await loadInstalled('system-type',{dataRoot});
|
||||
if (refused.length) throw Object.assign(new Error('system type module refused'), { code: 'system-type-invalid' });
|
||||
return modules.map(({ name, module }) => {
|
||||
const value = module.systemType();
|
||||
|
||||
@@ -111,8 +111,8 @@ export function checkTheme(value, name = null) {
|
||||
// Every installed theme module that loads and checks, as [{ id, displayName, tokens }] sorted by
|
||||
// id, and what was refused. Each refusal goes to `log` as the loader's plugin-refused entry.
|
||||
// Nothing names the themes: loadInstalled lists packages/plugins/theme/.
|
||||
export async function installedThemes({ log = () => {}, barrier } = {}) {
|
||||
const { modules, refused } = await loadInstalled("theme", { barrier });
|
||||
export async function installedThemes({ log = () => {}, barrier, dataRoot = null } = {}) {
|
||||
const { modules, refused } = await loadInstalled("theme", { barrier, dataRoot });
|
||||
const themes = [], problems = [...refused];
|
||||
for (const { name, module } of modules) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// Trusted host dispatch for declared tools. No plugin code is imported until the
|
||||
// current organization setting and the complete installed package pass verification.
|
||||
import { spawn } from 'node:child_process';
|
||||
import { existsSync, realpathSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url';
|
||||
import { organizationPlugins, assertPluginCapabilities, pluginName } from './organization.mjs';
|
||||
import { verifiedPlugin, claimPluginImport } from './pinned.mjs';
|
||||
import { bad, validArguments } from './manifest.mjs';
|
||||
const SCRIPT = `let input=''; for await(const b of process.stdin)input+=b;
|
||||
const {guardPluginImports}=await import('file:///plugin-guard.mjs');guardPluginImports('/plugin');
|
||||
const {entry,args}=JSON.parse(input); const m=await import(entry);
|
||||
process.stdout.write(JSON.stringify(await m.execute(args)));`;
|
||||
function sandboxTool(dir,entry,args) {
|
||||
// A fresh mount/network/user namespace with only runtime libraries and the pinned package.
|
||||
// No home, config, workspace, sockets or inherited environment is exposed.
|
||||
const binds=['/usr','/lib','/lib64','/bin'].filter(existsSync).flatMap(p=>['--ro-bind',realpathSync(p),p]);
|
||||
return new Promise((resolve,reject)=>{
|
||||
const child=spawn('bwrap',['--die-with-parent','--unshare-all','--new-session','--proc','/proc','--dev','/dev','--tmpfs','/tmp',...binds,'--ro-bind',realpathSync(process.execPath),'/node','--ro-bind',fileURLToPath(new URL('./imports.mjs',import.meta.url)),'/plugin-guard.mjs','--ro-bind',dir,'/plugin','--chdir','/plugin','--clearenv','/node','--input-type=module','-e',SCRIPT],{env:{PATH:'/usr/bin:/bin'},stdio:['pipe','pipe','pipe']});
|
||||
let out='',size=0;const timer=setTimeout(()=>child.kill('SIGKILL'),30000);
|
||||
child.stdout.on('data',b=>{size+=b.length;if(size>1024*1024)child.kill('SIGKILL');else out+=b;});
|
||||
child.stderr.resume();child.on('error',()=>{clearTimeout(timer);reject(new Error('sandbox refused'));});
|
||||
child.stdin.on('error',()=>{});child.stdin.end(JSON.stringify({entry:`file:///plugin/${entry}`,args}));
|
||||
child.on('close',code=>{clearTimeout(timer);try{if(code!==0 || size>1024*1024)throw Error();resolve(JSON.parse(out));}catch{reject(new Error('sandbox refused'));}});
|
||||
});
|
||||
}
|
||||
export function pluginService({dataRoot,read}) {
|
||||
function check(business,instance) {
|
||||
const b=read(business);const entry=b.roles?.[instance];let role=b.definitions?.[entry?.definition] ?? entry;
|
||||
if(!role)bad('plugin-disabled');
|
||||
role=structuredClone(role);
|
||||
for(const vars of [b.vars,entry?.vars]) {
|
||||
if(Array.isArray(vars?.['limits.tools']))role.tools=(role.tools ?? []).filter(t=>vars['limits.tools'].includes(t));
|
||||
if(Array.isArray(vars?.['limits.authority']) && role.authority)for(const key of ['withinRole','crossRole'])role.authority[key]=(role.authority[key] ?? []).filter(a=>vars['limits.authority'].includes(a));
|
||||
}
|
||||
const view=organizationPlugins(dataRoot,b.plugins);assertPluginCapabilities(role,view);return {role,view};
|
||||
}
|
||||
async function call(business,instance,name,args) {
|
||||
const prefix=pluginName(name);if(!prefix)bad('invalid-request');
|
||||
const {role,view}=check(business,instance);
|
||||
if(!role.tools?.includes(name))bad('not-authorized');
|
||||
const tool=view.manifests.flatMap(m=>m.tools).find(t=>t.name===name);if(!tool)bad('plugin-disabled',prefix);
|
||||
if(!validArguments(args,tool.arguments))bad('invalid-request');
|
||||
const checked=verifiedPlugin(dataRoot,prefix),{dir,manifest}=checked;
|
||||
if(tool.runs==='sandbox') {
|
||||
try {return await sandboxTool(dir,tool.entry,structuredClone(args));}
|
||||
catch {bad('plugin-tool-failed',prefix);}
|
||||
}
|
||||
claimPluginImport(checked);
|
||||
let module;
|
||||
try {module=await import(pathToFileURL(join(dir,tool.entry)).href+`?pin=${manifest.sha256}`);}
|
||||
catch {bad('plugin-tool-failed',prefix);}
|
||||
// A concurrent disable while import was in flight must refuse before execute.
|
||||
check(business,instance);verifiedPlugin(dataRoot,prefix);
|
||||
if(typeof module.execute!=='function')bad('missing-export');
|
||||
// Plugin code cannot impersonate a trusted refusal by throwing a matching error name.
|
||||
try {return await module.execute(structuredClone(args));}
|
||||
catch {bad('plugin-tool-failed',prefix);}
|
||||
}
|
||||
return {check,call};
|
||||
}
|
||||
@@ -27,6 +27,9 @@ export function tree(t) {
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const pkg = join(root, "packages", "plugins");
|
||||
cpSync(SRC, join(pkg, "src"), { recursive: true });
|
||||
const org = join(root,"packages","organization","src");
|
||||
mkdirSync(org,{recursive:true});
|
||||
for (const file of ["composition.mjs","vocabulary.mjs","errors.mjs","util.mjs"]) cpSync(join(SRC,"..","..","organization","src",file),join(org,file));
|
||||
const write = (kind, name, body) => {
|
||||
const dir = join(pkg, kind, name);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
@@ -105,6 +105,7 @@ test("REQ-PLUG-1 a symlinked packages/plugins refuses (run with --preserve-symli
|
||||
t.after(() => rmSync(other, { recursive: true, force: true }));
|
||||
mkdirSync(join(other, "packages"));
|
||||
symlinkSync(s.pkg, join(other, "packages", "plugins"));
|
||||
symlinkSync(join(s.root,"packages","organization"), join(other,"packages","organization"));
|
||||
const url = new URL(`file://${join(other, "packages", "plugins", "src", "loader.mjs")}`).href;
|
||||
const script = `const { createLoader } = await import(${JSON.stringify(url)});
|
||||
const logged = []; const loader = createLoader({ log: e => logged.push(e) });
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { checkManifest } from '../src/manifest.mjs';
|
||||
import { pluginHash, readPluginLock, verifiedPlugin } from '../src/pinned.mjs';
|
||||
import { installPlugin, removePlugin } from '../src/install.mjs';
|
||||
import { createLoader } from '../src/loader.mjs';
|
||||
import { organizationPlugins, assertPluginCapabilities } from '../src/organization.mjs';
|
||||
import { pluginService } from '../src/tools.mjs';
|
||||
import { typedTools } from '../../harness/src/tools.mjs';
|
||||
import { resolveInstance } from '../../organization/src/resolve.mjs';
|
||||
import { pluginInstallCommand } from '../../cli/src/plugin-install.mjs';
|
||||
import { pluginSettings } from '../../organization/src/plugin-settings.mjs';
|
||||
import { hasModule } from '../src/loader.mjs';
|
||||
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
|
||||
const SECRET='sk-test-NOT-A-SECRET-1234';
|
||||
function fixture(t) {
|
||||
const root=mkdtempSync(join(tmpdir(),'pl1-')),src=join(root,'src'),dataRoot=join(root,'data');
|
||||
mkdirSync(src);mkdirSync(dataRoot);mkdirSync(join(src,'usage-source'));mkdirSync(join(src,'tools'));
|
||||
t.after(()=>{const thaw=p=>{const st=lstatSync(p);if(st.isSymbolicLink())return;if(st.isDirectory()){chmodSync(p,0o700);for(const n of readdirSync(p))thaw(join(p,n));}};thaw(root);rmSync(root,{recursive:true,force:true});});
|
||||
writeFileSync(join(src,'usage-source','index.mjs'),'export const interfaceVersion=1; export const readTurn=()=>"fixture";');
|
||||
writeFileSync(join(src,'tools','echo.mjs'),`export const execute=(args)=>({answer:args.text}); // ${SECRET}`);
|
||||
const manifest={name:'acmeplug',version:'1.0.0',kinds:['usage-source'],interfaceVersions:{'usage-source':1},sha256:'0'.repeat(64),source:{path:'fixture',version:'1.0.0'},signature:null,tools:[{name:'acmeplug.echo',description:SECRET,arguments:{type:'object',properties:{text:{type:'string'}},required:['text'],additionalProperties:false},runs:'host',entry:'tools/echo.mjs'}],actions:['acmeplug.publish'],services:[{name:'acmeplug.account',scopes:['read']}],roleTemplates:[{name:'acmeplug.writer',file:'writer.json'}]};
|
||||
writeFileSync(join(src,'writer.json'),'{}');
|
||||
const seal=()=>{writeFileSync(join(src,'plugin.json'),JSON.stringify(manifest));manifest.sha256=pluginHash(src);writeFileSync(join(src,'plugin.json'),JSON.stringify(manifest));return manifest.sha256;};
|
||||
const hash=seal();const install=(source=src,extra={})=>installPlugin({dataRoot,source,expectedHash:hash,...extra});
|
||||
return {root,src,dataRoot,manifest,seal,hash,install};
|
||||
}
|
||||
const refused=code=>e=>e.code===code && !e.message.includes(SECRET);
|
||||
test('PL1 strict fields, kinds, core names, prefixes, hard waits and traversal refuse without echo',t=>{
|
||||
const f=fixture(t);
|
||||
for(const mutate of [m=>m.extra=SECRET,m=>m.tools[0].extra=SECRET,m=>m.kinds=['unknown'],m=>m.tools[0].name='read',m=>m.tools[0].name='acmeplug.read',m=>m.actions=['acmeplug.deploy'],m=>m.actions=['acmeplug.git.push.remote'],m=>m.roleTemplates[0].file='../file',m=>m.tools[0].arguments.$ref=SECRET,m=>m.name='git']){
|
||||
const m=structuredClone(f.manifest);mutate(m);assert.throws(()=>checkManifest(m),e=>!e.message.includes(SECRET));
|
||||
}
|
||||
});
|
||||
test('PL1 directory install is pinned and read-only; host tools printed before any instance write',t=>{
|
||||
const f=fixture(t),printed=[];const entry=f.install(f.src,{print:line=>{assert.deepEqual(readPluginLock(f.dataRoot).plugins,{});assert.equal(existsSync(join(f.dataRoot,'plugins')),false);printed.push(line);}});
|
||||
assert.equal(entry.sha256,f.hash);assert.deepEqual(printed,['host tool: acmeplug.echo']);
|
||||
for(const p of ['plugins/acmeplug','plugins/acmeplug/tools'])assert.equal(lstatSync(join(f.dataRoot,p)).mode&0o777,0o555);
|
||||
assert.equal(lstatSync(join(f.dataRoot,'plugins/acmeplug/plugin.json')).mode&0o777,0o444);
|
||||
assert.equal(verifiedPlugin(f.dataRoot,'acmeplug').manifest.sha256,f.hash);
|
||||
});
|
||||
test('PL1 mismatch and symlink sources refuse before instance writes',t=>{
|
||||
const f=fixture(t);assert.throws(()=>f.install(f.src,{expectedHash:'f'.repeat(64)}),refused('hash-mismatch'));
|
||||
symlinkSync('/etc/passwd',join(f.src,'escape'));assert.throws(()=>f.install(),refused('unsafe-path'));
|
||||
assert.equal(existsSync(join(f.dataRoot,'plugins')),false);
|
||||
});
|
||||
test('PL1 zip traversal, symlinks, and git URL without a full commit refuse',t=>{
|
||||
const f=fixture(t);const zip=join(f.root,'bad.zip');
|
||||
for(const script of ["z.writestr('../escape','bad')","e=zipfile.ZipInfo('link');e.create_system=3;e.external_attr=(0o120777<<16);z.writestr(e,'/etc/passwd')"]){
|
||||
assert.equal(spawnSync('python3',['-c',`import zipfile\nz=zipfile.ZipFile(${JSON.stringify(zip)},'w')\n${script}\nz.close()`]).status,0);
|
||||
assert.throws(()=>f.install(zip),refused('source-refused'));
|
||||
}
|
||||
assert.throws(()=>f.install('https://example.invalid/repo.git'),refused('commit-required'));
|
||||
assert.throws(()=>f.install(f.src,{commit:'abc'}),refused('commit-required'));
|
||||
});
|
||||
test('PL1 directory, generated zip and local bare git at full commit land the same hash',t=>{
|
||||
const f=fixture(t);const zip=join(f.root,'source.zip');
|
||||
assert.equal(spawnSync('python3',['-c',`import zipfile,os\nz=zipfile.ZipFile(${JSON.stringify(zip)},'w')\nfor root,dirs,files in os.walk(${JSON.stringify(f.src)}):\n for n in files:z.write(os.path.join(root,n),os.path.relpath(os.path.join(root,n),${JSON.stringify(f.src)}))\nz.close()`]).status,0);
|
||||
const git=(args)=>{const r=spawnSync('git',args,{encoding:'utf8'});assert.equal(r.status,0,r.stderr);return r.stdout.trim();};
|
||||
git(['init',f.src]);git(['-C',f.src,'add','.']);git(['-C',f.src,'-c','user.name=Fixture','-c','[email protected]','commit','-m','fixture']);
|
||||
const commit=git(['-C',f.src,'rev-parse','HEAD']),bare=join(f.root,'bare.git');git(['clone','--bare',f.src,bare]);
|
||||
// The local directory used for install intentionally has no .git administrative files.
|
||||
rmSync(join(f.src,'.git'),{recursive:true});
|
||||
for(const [source,opts]of [[f.src,{}],[zip,{}],[bare,{commit}]]){
|
||||
const d=mkdtempSync(join(f.root,'instance-'));const r=installPlugin({dataRoot:d,source,expectedHash:f.hash,...opts});assert.equal(r.sha256,f.hash);assert.equal(readPluginLock(d).plugins.acmeplug.sha256,f.hash);
|
||||
}
|
||||
});
|
||||
test('PL1 changed file/missing lock refuses next load and evicts installed code, core keeps running',async t=>{
|
||||
const f=fixture(t);f.install();const logs=[],loader=createLoader({dataRoot:f.dataRoot,log:e=>logs.push(e)});
|
||||
const settings={'usage-source':['transcript','acmeplug'],harness:['pi']};assert.equal((await loader.load(settings)).refused.length,0);
|
||||
const file=join(f.dataRoot,'plugins/acmeplug/tools/echo.mjs');chmodSync(file,0o600);writeFileSync(file,SECRET);
|
||||
const result=await loader.load(settings);assert.equal(result.refused[0].code,'hash-mismatch');assert.deepEqual(loader.loaded()['usage-source'],['transcript']);assert.deepEqual(loader.loaded().harness,['pi']);
|
||||
assert.ok(!JSON.stringify(logs).includes(SECRET));
|
||||
rmSync(join(f.dataRoot,'plugins.lock.json'));assert.equal((await loader.load(settings)).refused[0].code,'missing-lock');
|
||||
});
|
||||
test('PL1 registry includes only enabled verified declarations, services and templates',t=>{
|
||||
const f=fixture(t);f.install();const view=organizationPlugins(f.dataRoot,{'usage-source':['acmeplug']});
|
||||
assert.ok(view.registry.tools.includes('acmeplug.echo'));assert.ok(view.registry.routed.includes('acmeplug.publish'));assert.deepEqual(view.registry.services['acmeplug.account'].scopes,['read']);assert.equal(view.roleTemplates[0].name,'acmeplug.writer');
|
||||
for(const settings of [{},{'usage-source':['missing']}])assert.throws(()=>assertPluginCapabilities({tools:['acmeplug.echo']},organizationPlugins(f.dataRoot,settings)),e=>e.code==='plugin-disabled' && e.plugin==='acmeplug' && !e.message.includes(SECRET));
|
||||
});
|
||||
test('PL1 disabled or missing tools/actions refuse at launch with name only, enabled tool is bundled',t=>{
|
||||
const f=fixture(t);f.install();const definition={name:'writer',contractPath:'/contract',tools:['acmeplug.echo'],network:'none',authority:{withinRole:['acmeplug.publish'],crossRole:[]},credentials:[],messaging:null,skills:[]};
|
||||
const business={id:'acme',roles:{writer:{definition:'writer',vars:{},credentials:{}}},definitions:{writer:definition},vars:{},arbiters:{}};
|
||||
const args={system:{dataRoot:f.dataRoot},business,instance:'writer',enforcePlugins:true};
|
||||
assert.throws(()=>resolveInstance(args),e=>e.code==='plugin-disabled' && e.plugin==='acmeplug');
|
||||
business.plugins={'usage-source':['acmeplug']};const r=resolveInstance(args);assert.equal(typedTools({resolved:r}).find(t=>t.name==='acmeplug.echo').pluginTool,'acmeplug.echo');
|
||||
business.plugins={};assert.throws(()=>resolveInstance(args),refused('plugin-disabled'));
|
||||
});
|
||||
test('PL1 running tool calls recheck enabled setting, pin, arguments and held tool',async t=>{
|
||||
const f=fixture(t);f.install();const role={tools:['acmeplug.echo'],authority:{withinRole:[],crossRole:[]}};let enabled=true;
|
||||
const service=pluginService({dataRoot:f.dataRoot,read:()=>({plugins:enabled?{'usage-source':['acmeplug']}:{},roles:{writer:role}})});
|
||||
assert.deepEqual(await service.call('acme','writer','acmeplug.echo',{text:'hello'}),{answer:'hello'});
|
||||
await assert.rejects(service.call('acme','writer','acmeplug.echo',{unexpected:SECRET}),refused('invalid-request'));
|
||||
enabled=false;await assert.rejects(service.call('acme','writer','acmeplug.echo',{text:SECRET}),e=>e.code==='plugin-disabled' && e.plugin==='acmeplug' && !e.message.includes(SECRET));
|
||||
});
|
||||
test('PL1 remove refuses enabled organizations; host CLI rejects organization/tester options and agents',async t=>{
|
||||
const f=fixture(t);f.install();assert.throws(()=>removePlugin({dataRoot:f.dataRoot,name:'acmeplug',enabled:()=>true}),refused('plugin-enabled'));
|
||||
const dir=join(f.root,'config');mkdirSync(dir,{mode:0o700});let out='';const io={env:{},stdout:{write:v=>out+=v}};
|
||||
for(const args of [['install',f.src,'--sha256',f.hash,'--org','acme'],['install',f.src,'--sha256',f.hash,'--tester']])await assert.rejects(pluginInstallCommand(args,io,{system:{dataRoot:f.dataRoot},dir}));
|
||||
await assert.rejects(pluginInstallCommand(['list'],{...io,env:{MOSAIC_RUN_ID:'fixture'}},{system:{dataRoot:f.dataRoot},dir}));
|
||||
writeJson(join(dir,'businesses','acme.json'),{plugins:{'usage-source':['acmeplug']}});
|
||||
await assert.rejects(pluginInstallCommand(['remove','acmeplug'],io,{system:{dataRoot:f.dataRoot},dir}),e=>e.message.includes('plugin-enabled'));
|
||||
});
|
||||
test('PL1 enabling a tampered installed plugin refuses before organization write',t=>{
|
||||
const f=fixture(t);f.install();const doc=businessDoc(f.root),rolesDir=rolesCopy(f.root);writeJson(join(f.root,'businesses','acme.json'),doc);
|
||||
const settings=pluginSettings({dir:f.root,rolesDir,dataRoot:f.dataRoot,exists:(k,n)=>hasModule(k,n,f.dataRoot)});
|
||||
const before=settings.read('acme');const file=join(f.dataRoot,'plugins/acmeplug/tools/echo.mjs');chmodSync(file,0o600);writeFileSync(file,SECRET);
|
||||
assert.throws(()=>settings.set('acme',{revision:before.revision,plugins:{'usage-source':['acmeplug']}},'jason'),refused('invalid-plugins'));
|
||||
assert.deepEqual(settings.read('acme'),before);
|
||||
});
|
||||
|
||||
test('PL1 sandbox tools execute with no home or network namespace access',async t=>{
|
||||
const f=fixture(t);f.manifest.tools[0].runs='sandbox';
|
||||
writeFileSync(join(f.src,'tools/echo.mjs'),`import {existsSync} from 'node:fs';export const execute=()=>({home:existsSync('/home'),config:existsSync('/mnt/storage')});`);
|
||||
const hash=f.seal();f.install(f.src,{expectedHash:hash});
|
||||
const service=pluginService({dataRoot:f.dataRoot,read:()=>({plugins:{'usage-source':['acmeplug']},roles:{writer:{tools:['acmeplug.echo'],authority:{withinRole:[],crossRole:[]}}}})});
|
||||
assert.deepEqual(await service.call('acme','writer','acmeplug.echo',{text:'hello'}),{home:false,config:false});
|
||||
});
|
||||
|
||||
test('PL1 broker launch and running dispatch enforce enable state; organization install and member/agent enable refuse',async t=>{
|
||||
const {Store}=await import('../../bus/src/store.mjs');const {Broker}=await import('../../bus/src/broker.mjs');
|
||||
const f=fixture(t);f.install();const authority={withinRole:[],crossRole:[]};
|
||||
const business={id:'acme',human:'owner',plugins:{'usage-source':['acmeplug']},arbiters:{technical:'chief',delivery:'chief'},roles:{chief:{authority},writer:{tools:['acmeplug.echo'],authority}}};
|
||||
const service=pluginService({dataRoot:f.dataRoot,read:()=>business});const store=new Store(f.dataRoot);t.after(()=>store.close());
|
||||
const b=new Broker({store,businesses:{acme:business},pluginService:service});
|
||||
const cap=b.bindLaunch({business:'acme',role:'writer',run:'writer-1',harness:'pi'});b.request(cap,{verb:'role.claim'});
|
||||
assert.deepEqual(await b.request(cap,{verb:'plugin.tool',args:{tool:'acmeplug.echo',arguments:{text:'working'}}}),{answer:'working'});
|
||||
const member=b.bindReader({business:'acme'});
|
||||
for(const c of [cap,member])assert.throws(()=>b.request(c,{verb:'plugins.set',args:{revision:'a'.repeat(64),plugins:{'usage-source':['acmeplug']}}}),/human-required/);
|
||||
assert.throws(()=>b.request(cap,{verb:'plugins.install',args:{source:SECRET}}),e=>!e.message.includes(SECRET));
|
||||
business.plugins={};
|
||||
await assert.rejects(b.request(cap,{verb:'plugin.tool',args:{tool:'acmeplug.echo',arguments:{text:SECRET}}}),e=>e.code==='plugin-disabled' && e.reason==='acmeplug' && !e.message.includes(SECRET));
|
||||
assert.throws(()=>b.bindLaunch({business:'acme',role:'writer',run:'writer-2',harness:'pi'}),e=>e.code==='plugin-disabled' && e.reason==='acmeplug');
|
||||
assert.equal(b.bindLaunch({business:'acme',role:'chief',run:'chief-1',harness:'pi'}).length,64,'core can still launch');
|
||||
});
|
||||
|
||||
test('PL1 registry preserves capability categories and credential scope ceilings',t=>{
|
||||
const f=fixture(t);f.install();const view=organizationPlugins(f.dataRoot,{'usage-source':['acmeplug']});
|
||||
for(const role of [{tools:['acmeplug.publish']},{authority:{withinRole:['acmeplug.echo']}},{credentials:[{service:'acmeplug.account',scopes:['admin']}]}])assert.throws(()=>assertPluginCapabilities(role,view),refused('invalid-request'));
|
||||
});
|
||||
|
||||
test('PL1 manifest whitespace is pinned; remove succeeds after the owner disables it',t=>{
|
||||
const f=fixture(t);f.install();const file=join(f.dataRoot,'plugins/acmeplug/plugin.json');chmodSync(file,0o600);writeFileSync(file,readFileSync(file,'utf8')+'\n');
|
||||
assert.throws(()=>verifiedPlugin(f.dataRoot,'acmeplug'),refused('hash-mismatch'));
|
||||
removePlugin({dataRoot:f.dataRoot,name:'acmeplug',enabled:()=>false});
|
||||
assert.deepEqual(readPluginLock(f.dataRoot).plugins,{});assert.equal(existsSync(join(f.dataRoot,'plugins/acmeplug')),false);
|
||||
});
|
||||
|
||||
test('PL1 CLI host install/list/remove never calls a bus transport',async t=>{
|
||||
const {main}=await import('../../cli/src/cli.mjs');const f=fixture(t),dir=join(f.root,'config');mkdirSync(dir,{mode:0o700});
|
||||
let output='';const io={env:{},stdout:{write:v=>output+=v},stderr:{write:()=>{}}};const deps={system:{dataRoot:f.dataRoot},accountsConfigDir:dir,transport:()=>{throw Error('bus must not be used');}};
|
||||
await main(['plugins','install',f.src,'--sha256',f.hash],io,deps);assert.match(output,/host tool: acmeplug.echo/);assert.ok(!output.includes(SECRET));
|
||||
output='';await main(['plugins','list'],io,deps);assert.equal(JSON.parse(output).plugins.acmeplug.sha256,f.hash);
|
||||
output='';await main(['plugins','remove','acmeplug'],io,deps);assert.deepEqual(JSON.parse(output),{removed:'acmeplug'});
|
||||
});
|
||||
|
||||
test('PL1 a running plugin tool obeys a newly narrowed tool ceiling',async t=>{
|
||||
const f=fixture(t);f.install();const b={plugins:{'usage-source':['acmeplug']},roles:{writer:{tools:['acmeplug.echo'],authority:{withinRole:[],crossRole:[]},vars:{}}}};
|
||||
const service=pluginService({dataRoot:f.dataRoot,read:()=>b});assert.deepEqual(await service.call('acme','writer','acmeplug.echo',{text:'before'}),{answer:'before'});
|
||||
b.roles.writer.vars['limits.tools']=['read'];await assert.rejects(service.call('acme','writer','acmeplug.echo',{text:'after'}),refused('not-authorized'));
|
||||
});
|
||||
|
||||
test('PL1 an installed package cannot reuse a core module name from another kind',t=>{
|
||||
const f=fixture(t);f.manifest.name='pi';for(const tool of f.manifest.tools)tool.name=tool.name.replace('acmeplug.','pi.');f.manifest.actions=['pi.publish'];f.manifest.services[0].name='pi.account';f.manifest.roleTemplates[0].name='pi.writer';
|
||||
const hash=f.seal();assert.throws(()=>f.install(f.src,{expectedHash:hash}),refused('core-name'));
|
||||
assert.equal(existsSync(join(f.dataRoot,'plugins')),false);
|
||||
});
|
||||
|
||||
test('PL1 malformed settings cannot retain an installed module whose pin is now bad',async t=>{
|
||||
const f=fixture(t);f.install();const loader=createLoader({dataRoot:f.dataRoot,log:()=>{}});await loader.load({'usage-source':['transcript','acmeplug'],harness:['pi']});
|
||||
const file=join(f.dataRoot,'plugins/acmeplug/tools/echo.mjs');chmodSync(file,0o600);writeFileSync(file,SECRET);
|
||||
await loader.load(null);assert.deepEqual(loader.loaded()['usage-source'],['transcript']);assert.deepEqual(loader.loaded().harness,['pi']);
|
||||
});
|
||||
|
||||
test('PL1 runtime discovers installed harness levels from the instance root',async t=>{
|
||||
const {harnessLevels}=await import('../../bus/src/runtime.mjs');
|
||||
const {KINDS}=await import('../src/kinds.mjs');
|
||||
const f=fixture(t);f.manifest.kinds=['harness'];f.manifest.interfaceVersions={harness:1};
|
||||
mkdirSync(join(f.src,'harness'));
|
||||
writeFileSync(join(f.src,'harness/index.mjs'),KINDS.harness.exports.map(name=>`export function ${name}(){return ${name==='levels'?"['high']":'null'};}`).join('\n')+'\nexport const interfaceVersion=1;');
|
||||
const hash=f.seal();f.install(f.src,{expectedHash:hash});
|
||||
const levels=await harnessLevels({dataRoot:f.dataRoot,log:()=>{}});
|
||||
assert.deepEqual(levels('acmeplug'),['high']);assert.ok(levels.names.includes('pi'));
|
||||
});
|
||||
|
||||
test('PL1 invalid UTF-8 manifest bytes refuse instead of normalizing outside the pin',t=>{
|
||||
const f=fixture(t),path=join(f.src,'plugin.json'),bytes=readFileSync(path);
|
||||
bytes[bytes.indexOf(SECRET)]=0xff;writeFileSync(path,bytes);
|
||||
assert.throws(()=>pluginHash(f.src),refused('invalid-manifest'));
|
||||
});
|
||||
|
||||
test('PL1 tool exceptions cannot impersonate trusted refusals or echo payload bytes',async t=>{
|
||||
const f=fixture(t);
|
||||
writeFileSync(join(f.src,'tools/echo.mjs'),`export function execute(){throw Object.assign(new Error(${JSON.stringify(SECRET)}),{name:'PluginError',code:'plugin-disabled',plugin:${JSON.stringify(SECRET)}});}`);
|
||||
const hash=f.seal();f.install(f.src,{expectedHash:hash});
|
||||
const service=pluginService({dataRoot:f.dataRoot,read:()=>({plugins:{'usage-source':['acmeplug']},roles:{writer:{tools:['acmeplug.echo'],authority:{withinRole:[],crossRole:[]}}}})});
|
||||
await assert.rejects(service.call('acme','writer','acmeplug.echo',{text:'hello'}),e=>e.code==='plugin-tool-failed' && e.plugin==='acmeplug' && !e.message.includes(SECRET));
|
||||
});
|
||||
|
||||
test('PL1 A3 organization copy readers preserve the derived installed-plugin root',async t=>{
|
||||
const {withCopies,loadWithCopies}=await import('../../organization/src/agent-copies.mjs');
|
||||
const f=fixture(t);f.manifest.kinds=['system-type'];f.manifest.interfaceVersions={'system-type':1};
|
||||
mkdirSync(join(f.src,'system-type'));writeFileSync(join(f.src,'system-type/index.mjs'),'export const interfaceVersion=1;export function systemType(){return {}};');
|
||||
const hash=f.seal();f.install(f.src,{expectedHash:hash});
|
||||
const doc=businessDoc(f.root),rolesDir=rolesCopy(f.root);doc.plugins={...doc.plugins,'system-type':['acmeplug']};writeJson(join(f.root,'businesses/acme.json'),doc);
|
||||
const options={rolesDir,dataRoot:f.dataRoot,snapshot:null};
|
||||
assert.deepEqual(withCopies(doc,join(f.root,'businesses/acme.json'),options).plugins['system-type'],['acmeplug']);
|
||||
assert.deepEqual(loadWithCopies('acme',{...options,dir:f.root}).plugins['system-type'],['acmeplug']);
|
||||
const installed=join(f.dataRoot,'plugins/acmeplug/system-type/index.mjs');chmodSync(installed,0o600);writeFileSync(installed,SECRET);
|
||||
// Config checks names only; the loader owns bad-pin refusal and preserves core.
|
||||
assert.deepEqual(withCopies(doc,join(f.root,'businesses/acme.json'),options).plugins['system-type'],['acmeplug']);
|
||||
doc.plugins['system-type']=['defualt'];
|
||||
assert.throws(()=>withCopies(doc,join(f.root,'businesses/acme.json'),options),/unknown module/);
|
||||
});
|
||||
|
||||
test('PL1 replacement pins refuse across loader and host tools until a fresh process',async t=>{
|
||||
for(const first of ['loader','tool']) {
|
||||
const f=fixture(t);
|
||||
writeFileSync(join(f.src,'helper.mjs'),'export const value=1;');
|
||||
writeFileSync(join(f.src,'usage-source/index.mjs'),"import {value} from '../helper.mjs';export const interfaceVersion=1;export const readTurn=()=>value;");
|
||||
writeFileSync(join(f.src,'tools/echo.mjs'),"import {value} from '../helper.mjs';export const execute=()=>value;");
|
||||
const pin=f.seal();f.install(f.src,{expectedHash:pin});
|
||||
const settings={'usage-source':['transcript','acmeplug'],harness:['pi']};
|
||||
const loader=createLoader({dataRoot:f.dataRoot,log:()=>{}});
|
||||
const service=pluginService({dataRoot:f.dataRoot,read:()=>({plugins:settings,roles:{writer:{tools:['acmeplug.echo'],authority:{withinRole:[],crossRole:[]}}}})});
|
||||
if(first==='loader'){await loader.load(settings);assert.equal(loader.modules('usage-source')[1].readTurn(),1);}
|
||||
else assert.equal(await service.call('acme','writer','acmeplug.echo',{text:'before'}),1);
|
||||
removePlugin({dataRoot:f.dataRoot,name:'acmeplug',enabled:()=>false});
|
||||
writeFileSync(join(f.src,'helper.mjs'),'export const value=2;');f.manifest.version='2.0.0';
|
||||
const next=f.seal();assert.notEqual(next,pin);f.install(f.src,{expectedHash:next});
|
||||
// Verification, new loaders, and the other import path share one process latch.
|
||||
assert.throws(()=>verifiedPlugin(f.dataRoot,'acmeplug'),refused('plugin-restart-required'));
|
||||
await assert.rejects(service.call('acme','writer','acmeplug.echo',{text:'after'}),refused('plugin-disabled'));
|
||||
const freshLoader=createLoader({dataRoot:f.dataRoot,log:()=>{}}),result=await freshLoader.load(settings);
|
||||
assert.equal(result.refused[0].code,'plugin-restart-required');
|
||||
assert.deepEqual(result.loaded['usage-source'],['transcript']);assert.deepEqual(result.loaded.harness,['pi']);
|
||||
await loader.load(null);assert.deepEqual(loader.loaded()['usage-source'],first==='loader'?['transcript']:[]);
|
||||
const script=`import {createLoader} from ${JSON.stringify(import.meta.resolve('../src/loader.mjs'))};
|
||||
const loader=createLoader({dataRoot:process.argv[1],log:()=>{}});const r=await loader.load({'usage-source':['acmeplug']});
|
||||
console.log(JSON.stringify({refused:r.refused,value:loader.modules('usage-source')[0].readTurn()}));`;
|
||||
const child=spawnSync(process.execPath,['--input-type=module','-e',script,f.dataRoot],{encoding:'utf8'});
|
||||
assert.equal(child.status,0,child.stderr);assert.deepEqual(JSON.parse(child.stdout),{refused:[],value:2});
|
||||
}
|
||||
});
|
||||
|
||||
test('PL1 even a failed import claims its package pin before dependencies enter the cache',async t=>{
|
||||
const f=fixture(t);writeFileSync(join(f.src,'helper.mjs'),'export const value=1;');
|
||||
writeFileSync(join(f.src,'usage-source/index.mjs'),"import '../helper.mjs';throw Error('fixture');");
|
||||
let pin=f.seal();f.install(f.src,{expectedHash:pin});
|
||||
const loader=createLoader({dataRoot:f.dataRoot,log:()=>{}});
|
||||
assert.equal((await loader.load({'usage-source':['acmeplug']})).refused[0].code,'load-failed');
|
||||
removePlugin({dataRoot:f.dataRoot,name:'acmeplug',enabled:()=>false});
|
||||
writeFileSync(join(f.src,'usage-source/index.mjs'),'export const interfaceVersion=1;export const readTurn=()=>2;');
|
||||
pin=f.seal();f.install(f.src,{expectedHash:pin});
|
||||
assert.equal((await loader.load({'usage-source':['transcript','acmeplug']})).refused[0].code,'plugin-restart-required');
|
||||
assert.deepEqual(loader.loaded()['usage-source'],['transcript']);
|
||||
});
|
||||
|
||||
test('PL1 plugin imports refuse unpinned bare, escaping, dynamic and require dependencies',async t=>{
|
||||
for(const spec of ['extdep','../../../outside.mjs']) {
|
||||
const f=fixture(t),marker=join(f.dataRoot,'executed');
|
||||
const outside=`import {writeFileSync} from 'node:fs';writeFileSync(${JSON.stringify(marker)},'ran');export const value=1;`;
|
||||
mkdirSync(join(f.dataRoot,'node_modules/extdep'),{recursive:true});
|
||||
writeFileSync(join(f.dataRoot,'node_modules/extdep/package.json'),JSON.stringify({type:'module',main:'index.mjs'}));
|
||||
writeFileSync(join(f.dataRoot,'node_modules/extdep/index.mjs'),outside);writeFileSync(join(f.dataRoot,'outside.mjs'),outside);
|
||||
writeFileSync(join(f.src,'usage-source/index.mjs'),`import ${JSON.stringify(spec)};export const interfaceVersion=1;export const readTurn=()=>1;`);
|
||||
const pin=f.seal();f.install(f.src,{expectedHash:pin});
|
||||
const result=await createLoader({dataRoot:f.dataRoot,log:()=>{}}).load({'usage-source':['transcript','acmeplug'],harness:['pi']});
|
||||
assert.equal(result.refused[0].code,'load-failed');assert.deepEqual(result.loaded['usage-source'],['transcript']);assert.deepEqual(result.loaded.harness,['pi']);assert.equal(existsSync(marker),false);
|
||||
}
|
||||
for(const body of ["return (await import('../../../outside.mjs')).value;","return require('../../../outside.cjs');"]) {
|
||||
const f=fixture(t),marker=join(f.dataRoot,'executed');
|
||||
writeFileSync(join(f.dataRoot,'outside.mjs'),`import {writeFileSync} from 'node:fs';writeFileSync(${JSON.stringify(marker)},'ran');export const value=1;`);
|
||||
writeFileSync(join(f.dataRoot,'outside.cjs'),`require('node:fs').writeFileSync(${JSON.stringify(marker)},'ran');module.exports=1;`);
|
||||
writeFileSync(join(f.src,'tools/echo.mjs'),`import {createRequire} from 'node:module';const require=createRequire(import.meta.url);export async function execute(){${body}}`);
|
||||
const pin=f.seal();f.install(f.src,{expectedHash:pin});
|
||||
const service=pluginService({dataRoot:f.dataRoot,read:()=>({plugins:{'usage-source':['acmeplug']},roles:{writer:{tools:['acmeplug.echo'],authority:{withinRole:[],crossRole:[]}}}})});
|
||||
await assert.rejects(service.call('acme','writer','acmeplug.echo',{text:SECRET}),refused('plugin-tool-failed'));assert.equal(existsSync(marker),false);
|
||||
}
|
||||
});
|
||||
|
||||
test('PL1 sandbox tools also refuse executable data URLs outside the pinned package',async t=>{
|
||||
const f=fixture(t);f.manifest.tools[0].runs='sandbox';
|
||||
writeFileSync(join(f.src,'tools/echo.mjs'),"export async function execute(){return (await import('data:text/javascript,export default 42')).default;}");
|
||||
const pin=f.seal();f.install(f.src,{expectedHash:pin});
|
||||
const service=pluginService({dataRoot:f.dataRoot,read:()=>({plugins:{'usage-source':['acmeplug']},roles:{writer:{tools:['acmeplug.echo'],authority:{withinRole:[],crossRole:[]}}}})});
|
||||
await assert.rejects(service.call('acme','writer','acmeplug.echo',{text:SECRET}),refused('plugin-tool-failed'));
|
||||
});
|
||||
|
||||
test('PL1 lock package names use the manifest length bound',t=>{
|
||||
const f=fixture(t);writeFileSync(join(f.dataRoot,'plugins.lock.json'),JSON.stringify({version:1,plugins:{['a'.repeat(33)]:{version:'1.0.0',source:f.manifest.source,sha256:f.hash}}}),{mode:0o600});
|
||||
assert.throws(()=>readPluginLock(f.dataRoot),refused('invalid-lock'));
|
||||
assert.throws(()=>verifiedPlugin(f.dataRoot,'a'.repeat(33)),refused('bad-name'));
|
||||
});
|
||||
@@ -294,7 +294,7 @@
|
||||
d = document.createElement('dialog');
|
||||
d.id = 'tb-dialog'; d.className = 'tb-dialog'; d.setAttribute('aria-labelledby', 'tb-dialog-h');
|
||||
document.body.append(d);
|
||||
d.addEventListener('close', () => { const back = d.dataset.return; d.innerHTML = ''; opened = null; if (back) document.querySelector(`[data-focus-key="${CSS.escape(back)}"]`)?.focus(); });
|
||||
d.addEventListener('close', () => { const back = d.dataset.return; d.innerHTML = ''; d.removeAttribute('aria-describedby'); opened = null; if (back) document.querySelector(`[data-focus-key="${CSS.escape(back)}"]`)?.focus(); });
|
||||
}
|
||||
return d;
|
||||
}
|
||||
@@ -322,7 +322,7 @@
|
||||
<div class="tb-actions"><button type="button" class="ws-btn" data-tb-cancel>Cancel</button><button type="submit" class="ws-btn primary" data-tb-submit>Create task</button></div>
|
||||
</form>`;
|
||||
}
|
||||
function editForm(t) {
|
||||
function editForm(t, refusal = null) {
|
||||
const o = orgOf(t.org), w = taskWritable(t);
|
||||
const why = t.tracker === 'vikunja' ? 'This task is in Vikunja. The board reads it but does not change it; change it in Vikunja.'
|
||||
: !canWrite ? 'This Console takes no task changes.' : o.state !== 'ok' ? 'This organization\'s bus did not answer.' : !w ? 'The bus did not give this task a version to check a change against.' : '';
|
||||
@@ -331,6 +331,7 @@
|
||||
return `<div class="tb-form" data-tb-form="view"><h2 id="tb-dialog-h">${title(t)}</h2>${facts}
|
||||
<dl class="tb-dl"><dt>State</dt><dd>${esc(STATE_LABEL[t.state] || 'Not read')}</dd><dt>Assignees</dt><dd>${t.assignees.length ? esc(t.assignees.join(', ')) : 'nobody'}</dd><dt>Priority</dt><dd>${prio(t) || 'none'}</dd><dt>Due</dt><dd>${due(t) || 'none'}</dd></dl>
|
||||
<p class="tb-field-note">${esc(why)}</p>
|
||||
${refusal ? `<p id="tb-dialog-status" class="tb-form-status" role="status" data-tb-form-status>${esc(refusal)}</p>` : ''}
|
||||
<div class="tb-actions"><button type="button" class="ws-btn" data-tb-cancel>Close</button></div></div>`;
|
||||
}
|
||||
return `<form method="dialog" class="tb-form" data-tb-form="edit" data-tb-key="${esc(key(t))}">
|
||||
@@ -345,6 +346,7 @@
|
||||
function openDialog(html, back, task = null) {
|
||||
const d = dialogEl();
|
||||
d.innerHTML = html; d.dataset.return = back || ''; opened = task ? structuredClone(task) : null;
|
||||
if (d.querySelector('#tb-dialog-status')) d.setAttribute('aria-describedby', 'tb-dialog-status'); else d.removeAttribute('aria-describedby');
|
||||
if (!d.open) d.showModal();
|
||||
d.querySelector('input:not([type=checkbox]),select,button')?.focus();
|
||||
}
|
||||
@@ -371,6 +373,25 @@
|
||||
return (Array.isArray(b?.tasks) && b.tasks.find(x => x.org === t.org && x.ref === t.ref)) || null;
|
||||
} catch { return null; }
|
||||
}
|
||||
// A conflict replaces the stale form only once the current task has been read.
|
||||
async function reopenConflict(form, t, message) {
|
||||
formStatus(form, 'Reading the current task…');
|
||||
// A read a poll overtook leaves the board's old read in place, so only a read that landed counts.
|
||||
const fresh = await reload().catch(() => false);
|
||||
const now = (fresh && find(key(t))) || await current(t);
|
||||
// Closed (Escape, Cancel) while the task was read: the dialog stays closed. close() clears `open`
|
||||
// at once but queues the event that empties the dialog, so `open` is the check that holds.
|
||||
const d = dialogEl();
|
||||
if (!d.open || !d.contains(form)) { paint(); return; }
|
||||
if (now) { openDialog(editForm(now, message), dialogEl().dataset.return, now); const f2 = dialogEl().querySelector('form'); if (f2) formStatus(f2, message); }
|
||||
else {
|
||||
// The current task could not be read: the form still holds the old one, so it takes no save.
|
||||
opened = null;
|
||||
formStatus(form, 'The task changed after the board read it. Nothing was written, and the current task could not be read; close this form and open the task again.');
|
||||
form.querySelectorAll('[data-tb-cancel]').forEach(b => { b.disabled = false; });
|
||||
}
|
||||
paint();
|
||||
}
|
||||
async function submit(form) {
|
||||
const kind = form.dataset.tbForm, btns = form.querySelectorAll('button');
|
||||
// A form whose Save is disabled is sending, or waiting on the current task: it takes no second save.
|
||||
@@ -390,28 +411,7 @@
|
||||
}
|
||||
btns.forEach(b => { b.disabled = true; }); formStatus(form, 'Sending…');
|
||||
const r = await write(body);
|
||||
// The task changed under the form: the form reopens on the current task and keeps the refusal.
|
||||
// Until the current task is read the old form stays disabled and does not show the refusal, so
|
||||
// the refusal never stands beside the old task and no save goes out against its digest.
|
||||
if (!r.ok && r.error === 'task-conflict' && t) {
|
||||
formStatus(form, 'Reading the current task…');
|
||||
// A read a poll overtook leaves the board's old read in place, so only a read that landed counts.
|
||||
const fresh = await reload().catch(() => false);
|
||||
const now = (fresh && find(key(t))) || await current(t);
|
||||
// Closed (Escape, Cancel) while the task was read: the dialog stays closed. close() clears `open`
|
||||
// at once but queues the event that empties the dialog, so `open` is the check that holds.
|
||||
const d = dialogEl();
|
||||
if (!d.open || !d.contains(form)) { paint(); return; }
|
||||
if (now) { openDialog(editForm(now), dialogEl().dataset.return, now); const f2 = dialogEl().querySelector('form'); if (f2) formStatus(f2, r.message); }
|
||||
else {
|
||||
// The current task could not be read: the form still holds the old one, so it takes no save.
|
||||
opened = null;
|
||||
formStatus(form, 'The task changed after the board read it. Nothing was written, and the current task could not be read; close this form and open the task again.');
|
||||
form.querySelectorAll('[data-tb-cancel]').forEach(b => { b.disabled = false; });
|
||||
}
|
||||
paint();
|
||||
return;
|
||||
}
|
||||
if (!r.ok && r.error === 'task-conflict' && t) { await reopenConflict(form, t, r.message); return; }
|
||||
btns.forEach(b => { b.disabled = false; });
|
||||
if (!r.ok) {
|
||||
formStatus(form, r.message);
|
||||
@@ -425,6 +425,7 @@
|
||||
paint();
|
||||
}
|
||||
async function archive(form) {
|
||||
if (form.querySelector('[data-tb-archive]')?.disabled) return;
|
||||
const t = opened && key(opened) === form.dataset.tbKey ? opened : null;
|
||||
if (!t) return;
|
||||
if (!form.querySelector('[data-tb-archive]').hasAttribute('data-armed')) {
|
||||
@@ -432,7 +433,11 @@
|
||||
form.querySelector('[data-tb-archive]').textContent = 'Archive: press again to confirm';
|
||||
return;
|
||||
}
|
||||
const btns = form.querySelectorAll('button');
|
||||
btns.forEach(b => { b.disabled = true; }); formStatus(form, 'Sending…');
|
||||
const r = await write({ op: 'archive', org: t.org, task_ref: t.ref, expect: t.digest });
|
||||
if (!r.ok && r.error === 'task-conflict') { await reopenConflict(form, t, r.message); return; }
|
||||
btns.forEach(b => { b.disabled = false; });
|
||||
if (!r.ok) { formStatus(form, r.message); if (r.unknown) { try { await reload(); } catch {} paint(); } return; }
|
||||
notice = { text: `${shortId(t.ref)} archived in ${orgOf(t.org).label}.` };
|
||||
dialogEl().close();
|
||||
|
||||
@@ -28,6 +28,32 @@ const state = b => b.evaluate('JSON.parse(JSON.stringify(window.MosaicAppearance
|
||||
const stored = (b, key) => b.evaluate(`localStorage.getItem(${JSON.stringify(key)})`);
|
||||
const inline = b => b.evaluate('document.documentElement.getAttribute("style")');
|
||||
|
||||
test('an organization browser override draws system and explicit light from the light colours, including after reload', { timeout: 90000 }, async t => {
|
||||
const { b, f } = await open(t, { saved: { theme: 'rosewood', mode: 'dark' } });
|
||||
const rgb = hex => `rgb(${hex.slice(1).match(/../g).map(n => parseInt(n, 16)).join(', ')})`;
|
||||
const theme = THEMES.find(t => t.id === 'rosewood');
|
||||
for (const scheme of ['light', 'dark']) {
|
||||
await b.call('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: scheme }] });
|
||||
for (const mode of ['system', 'light']) {
|
||||
await b.evaluate(`window.MosaicAppearance.set({ mode: ${JSON.stringify(mode)} })`);
|
||||
// Read the actual scoped storage on a new document, not only the in-memory choice.
|
||||
await b.navigate(`${f.base}/?appearance=${scheme}-${mode}`);
|
||||
await b.evaluate('window.MosaicAppearance.ready.then(() => true)');
|
||||
const expected = mode === 'system' ? scheme : mode;
|
||||
const drawn = await b.evaluate(`({
|
||||
palette: document.documentElement.dataset.palette,
|
||||
mode: document.documentElement.getAttribute('data-mode'),
|
||||
canvas: getComputedStyle(document.body).backgroundColor,
|
||||
source: window.MosaicAppearance.state().modeSource,
|
||||
override: JSON.parse(localStorage.getItem('mosaic-console-appearance:acme')),
|
||||
})`);
|
||||
assert.deepEqual(drawn, { palette: 'rosewood', mode: mode === 'system' ? null : mode,
|
||||
canvas: rgb(theme.tokens[expected]['--canvas']), source: 'browser', override: { mode } }, `${scheme}/${mode}`);
|
||||
t.diagnostic(JSON.stringify({ scheme, requested: mode, ...drawn }));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('the built-in default is drawn by tokens.css alone: Harbor, dark, no inline colour, no warning, no cue, nothing stored', { timeout: 90000 }, async t => {
|
||||
const { b } = await open(t, { org: null });
|
||||
assert.equal(await b.evaluate('document.documentElement.dataset.palette'), 'harbor');
|
||||
|
||||
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user