Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3e82efe23 | ||
|
|
c8ff8ea128 | ||
|
|
ddf4f895cd | ||
|
|
7035ce61b3 | ||
|
|
54bf239e32 | ||
|
|
a4eed1e219 | ||
|
|
1e1bbaa6dd | ||
|
|
78bda9d6a4 | ||
|
|
6b39af7888 | ||
|
|
96c900d210 | ||
|
|
307fe928ac | ||
|
|
e9a079fcc4 | ||
|
|
7de9cfccc3 | ||
|
|
c3c192677d | ||
|
|
95ee19cc71 | ||
|
|
07dba7f2f5 | ||
|
|
ceee80d5b7 | ||
|
|
fa9bedb09a | ||
|
|
67d6c9ed84 | ||
|
|
c8dbd3f6ec |
No files matched your search
@@ -189,8 +189,15 @@ those messages stranded: no later holder receives or reads them, and `agents`
|
||||
lists them under `stranded` (Agent state, below). `message.read` is an explicit acknowledgement. `trail(messageId)` lets
|
||||
an operator inspect content and delivery evidence after an uncertain result.
|
||||
|
||||
Broker socket refusals retain `ok:false`, `error` and any scoped `reason`, and add
|
||||
`rail` and `nextStep` from `src/refusals.mjs`. Guidance is static and never
|
||||
quotes caller input or file bytes. The runner and typed tools carry the guidance
|
||||
to the agent. `src/rails.mjs` in the organization package supplies the shared
|
||||
prompt/live projection; `decisionAutonomy` is the extension point for AU.
|
||||
|
||||
| Verb | Arguments and result |
|
||||
|---|---|
|
||||
| `my_rails` | No args. Read only the current caller's held actions and routes, resolve rights with fixed autonomy levels, network, credential service/scope names and messaging targets. Recomputed from live policy on every call; never returns credential references or values, writes evidence, or raises a decision. Exposed to every managed role as `my_rails`. |
|
||||
| `role.claim` | No args; claims capability's role/run. One current holder per business/instance; a revoked run cannot reclaim that role, including after restart. |
|
||||
| `role.release` | No args; only its current holder releases. |
|
||||
| `role.revoke` | `{role,decision}`; needs an approved decision for this exact target and holder incarnation, routed as REQ-DEC-2 says: the human's for an uncited gated raise, the arbiter's for one citing the current spec. A role whose file lists `role.end` (the chief of staff, row 123, REQ-COS-1) sends `{role,reason}` instead: no decision, a reason of 1 to 500 characters, logged in the `action.allowed` body. Sending both is `invalid-request`; the caller's own run is `role-end-refused`; any other role sending `reason` gets `decision-required`. An instance whose definition is in `END_PROTECTED` (the assistant) is ended or revoked only by the chief of staff or with a decision the human resolved through the CLI (`via` `cli`); anyone else's reason, or a decision an arbiter resolved, is `role-end-refused` (Jason's ruling Q169, row 139). A target whose definition is `THREAD_DEFINITION` (the assistant) has no role claim: the end revokes the current claim of each of its threads, refuses `not-held` when none is held, and returns `{revoked:true,threads:N}` (row 155, #1649). |
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ownRails } from '../../organization/src/rails.mjs';
|
||||
import { refusalGuidance } from './refusals.mjs';
|
||||
import { PROXY_PAGE_VERBS, PROXY_PAGE_CODES } from './proxy-page-vocabulary.mjs';
|
||||
import { randomUUID, randomBytes, createHash } from 'node:crypto';
|
||||
import { validManagementKey, proxyTiming } from '../../model-proxy/src/validation.mjs';
|
||||
@@ -50,6 +52,7 @@ export const ACTIONS = Object.freeze([
|
||||
'policy.change',
|
||||
'prd.approve',
|
||||
'decision.resolve.technical',
|
||||
'decision.resolve.delivery',
|
||||
// Row 285 (C2-08b, #1790): routed under PRD 1.16 REQ-WAIT-3 and REQ-LIB-6, below.
|
||||
'library.write',
|
||||
]);
|
||||
@@ -78,6 +81,7 @@ const ROUTED = new Set([
|
||||
'policy.change',
|
||||
'prd.approve',
|
||||
'decision.resolve.technical',
|
||||
'decision.resolve.delivery',
|
||||
// PRD 1.7 REQ-COS-3 (row 194, #1692): the chief's hire and fire, within the bounds the broker checks.
|
||||
'role.hire',
|
||||
'role.fire',
|
||||
@@ -88,6 +92,8 @@ const ROUTED = new Set([
|
||||
export const HARD_WAITS = Object.freeze(ACTIONS.filter((action) => !ROUTED.has(action)));
|
||||
const GATED = new Set(HARD_WAITS);
|
||||
const CLOSED = "('resolved','withdrawn','expired')";
|
||||
// RR (#1873): these actions never take their domain from the raising agent.
|
||||
const DECISION_DOMAINS = Object.freeze({ 'git.merge.protected': 'technical', 'policy.change': 'delivery' });
|
||||
// How many closed decisions `decisions` with list 'recent' returns, newest close first.
|
||||
export const RECENT = 50;
|
||||
// How many replies `replies` returns, oldest first.
|
||||
@@ -241,10 +247,11 @@ const canonical = (x) =>
|
||||
Number.isFinite(Date.parse(x)) &&
|
||||
new Date(x).toISOString() === x;
|
||||
export class BusError extends Error {
|
||||
// Only explicitly scoped refusals carry a reason; the envelope passes it on.
|
||||
// Guidance is static policy text; never quote arguments or file bytes here.
|
||||
constructor(code, reason) {
|
||||
super(code);
|
||||
this.code = code;
|
||||
Object.assign(this, refusalGuidance(code));
|
||||
if (reason !== undefined) this.reason = reason;
|
||||
}
|
||||
}
|
||||
@@ -5061,6 +5068,20 @@ export class Broker {
|
||||
);
|
||||
return evidence ? JSON.parse(evidence.body) : null;
|
||||
}
|
||||
#checkResolver(s, d) {
|
||||
const b = this.#business(s.business), role = b.roles[s.role];
|
||||
const domain = d.action === 'prd.approve' ? 'technical' : DECISION_DOMAINS[d.action] ?? this.#raised(s.business, d.id)?.domain;
|
||||
// Pre-RR evidence has no domain. Assignments may have changed since raise,
|
||||
// so require both possible rights rather than infer one from today's file.
|
||||
const domains = domain === undefined ? ['technical', 'delivery'] : [domain];
|
||||
for (const kind of domains) {
|
||||
if (!['technical', 'delivery'].includes(kind)) fail('not-resolver');
|
||||
const right = `decision.resolve.${kind}`;
|
||||
if (!role?.authority?.withinRole.includes(right)) {
|
||||
throw new BusError('not-resolver', `instance ${s.role}, role ${role?.definition ?? s.role}, lacks ${right}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Whether an executor already used the decision. decision.raise records context, not permission
|
||||
// consumed by an executor.
|
||||
#spent(business, decision) {
|
||||
@@ -6920,6 +6941,13 @@ export class Broker {
|
||||
// Preserve the common request gates and refuse additions until the vocabulary names them.
|
||||
if (verb.startsWith('tasks.') && !PERSON_TASK_VERBS.includes(verb)) fail('unknown-verb');
|
||||
switch (verb) {
|
||||
case 'my_rails': {
|
||||
keys(a, []);
|
||||
this.#agent(s);
|
||||
const entry = b.roles[s.role], definition = b.definitions?.[entry.definition];
|
||||
return ownRails({ instance: s.role, authority: entry.authority, network: entry.network ?? definition?.network,
|
||||
credentials: definition?.credentials ?? [], messaging: definition?.messaging, arbiters: b.arbiters, launcher: b.launch?.by });
|
||||
}
|
||||
case 'role.claim': {
|
||||
keys(a, []);
|
||||
if (s.human) fail('agent-required');
|
||||
@@ -7064,8 +7092,8 @@ export class Broker {
|
||||
if (!choices.includes(a.recommendation)) fail('invalid-recommendation');
|
||||
const approvalChoice = a.approvalChoice ?? 'yes';
|
||||
if (!choices.includes(approvalChoice)) fail('invalid-approval-choice');
|
||||
const domain = a.domain ?? 'delivery';
|
||||
if (!['technical', 'delivery'].includes(domain)) fail('invalid-domain');
|
||||
if (a.domain !== undefined && !['technical', 'delivery'].includes(a.domain)) fail('invalid-domain');
|
||||
const domain = DECISION_DOMAINS[a.action] ?? a.domain ?? 'delivery';
|
||||
// PRD 1.5 REQ-DEC-2 (row 139, #1633): a gated raise citing the project's current spec commit
|
||||
// goes to the arbiter for its domain, like a cross-role one. An uncited gated raise and any
|
||||
// hard wait go to the human. readSpec already refused a stale or unconfirmed citation.
|
||||
@@ -7147,6 +7175,7 @@ export class Broker {
|
||||
decision: did,
|
||||
action: a.action,
|
||||
operation: 'decision.raise',
|
||||
domain,
|
||||
task_ref: a.task_ref ?? null,
|
||||
target: a.target ?? null,
|
||||
approvalChoice,
|
||||
@@ -7213,6 +7242,7 @@ export class Broker {
|
||||
else {
|
||||
this.#agent(s);
|
||||
if (s.role !== d.route_to) fail('not-resolver');
|
||||
if (verb === 'decision.resolve') this.#checkResolver(s, d);
|
||||
}
|
||||
const op = verb === 'decision.resolve' || viaConsole ? 'resolved' : verb === 'decision.seen' ? 'seen' : 'withdrawn';
|
||||
if (op === 'resolved' && !JSON.parse(d.options).some((o) => o.key === a.choice))
|
||||
|
||||
@@ -16,6 +16,7 @@ export function busOrganization(business, resolved) {
|
||||
!r.limits?.authority
|
||||
)
|
||||
throw new BusError('invalid-resolution');
|
||||
out.roles[role].network = r.limits.network;
|
||||
out.roles[role].authority = structuredClone(r.limits.authority);
|
||||
// REQ-HARN-2: "web" in the resolved tool limit is the grant for the broker's web verbs.
|
||||
out.roles[role].web = (r.limits.tools ?? []).includes('web');
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
// Static refusal table: each listed code maps to one rail and next step. These strings
|
||||
// never interpolate caller arguments, credential references, policy values or file bytes.
|
||||
const entries = [];
|
||||
const group = (codes, rail, nextStep) => {
|
||||
const guidance = Object.freeze({ rail, nextStep });
|
||||
for (const code of codes.split(' ')) {
|
||||
if (entries.some(([known]) => known === code)) throw new Error('duplicate refusal guidance');
|
||||
entries.push([code, guidance]);
|
||||
}
|
||||
};
|
||||
group(
|
||||
"access-refused access-revoked share-exists share-not-covered share-not-found tester-dev-only tester-off tester-refused tester-required tester-target-not-running thread-not-allowed thread-not-owned",
|
||||
"R7: caller access and scoped session boundary",
|
||||
"Use only the caller's own permitted scope. Ask the owner to inspect access or sharing through the supported controls; never borrow another identity."
|
||||
);
|
||||
group(
|
||||
"adapter-failed check-threw duplicate-run malformed-answer no-verdict not-a-merge not-approved not-collected not-human not-merge-option not-resolved not-thread-launch peercred-required reply-limits-unreadable socket-exists store-unreadable target-branch workspace-rename-failed",
|
||||
"R3: fail closed on an unsatisfied operation precondition",
|
||||
"Inspect the supported status and report the refusal code to the responsible owner; do not bypass it or retry an uncertain side effect."
|
||||
);
|
||||
group(
|
||||
"addressee-ambiguous invalid-approval-choice invalid-arbiter invalid-authority invalid-business invalid-choice invalid-class invalid-domain invalid-envelope invalid-harness invalid-messaging invalid-options invalid-plugins invalid-recommendation invalid-request invalid-snapshot invalid-spec-citation invalid-state invalid-task-ref invalid-timestamp name-is-instance name-is-role name-taken project-name-is-repo thread-instance-ambiguous unknown-action unknown-addressee unknown-business unknown-commit unknown-model unknown-plugin unknown-repo unknown-request unknown-requirement unknown-role unknown-run unknown-verb",
|
||||
"R3: validated vocabulary and request shape",
|
||||
"Check the tool schema and permitted identifiers, correct the request, then retry; do not guess around a refusal."
|
||||
);
|
||||
group(
|
||||
"agent-required",
|
||||
"Role/session: agent identity required",
|
||||
"Use this tool only from an authorized managed agent session."
|
||||
);
|
||||
group(
|
||||
"agent-running fire-in-progress instance-fired instance-firing instance-not-listed instance-retired instance-running instance-starting launch-record-mismatch launch-revoked launcher-released not-drained not-drained-run not-exited not-held not-holder not-recipient not-retired revoke-refused revoked role-already-held room-not-open room-thread-name-taken run-ended run-not-ended run-revoked session-clear session-clear-cancelled session-clear-complete session-clearing session-exists session-name-too-long session-not-found session-op-conflict session-retired session-revision-stale thread-already-held thread-exists thread-instance-mismatch thread-instance-missing thread-not-found thread-required thread-required-reply-to-thread-message thread-required-use-thread-send thread-revoked",
|
||||
"Role/session: current holder and lifecycle fence",
|
||||
"Read current session/status and ask the launcher or owner to finish the supported lifecycle transition. A stale or ended run must not reclaim another holder's identity."
|
||||
);
|
||||
group(
|
||||
"apikey-invalid callback-url-invalid harness-off harness-refused key-quota-no-model-key login-state-unknown model-catalogue-unavailable model-harness-incompatible model-key-collision model-provider-unavailable models-ambiguous models-invalid models-unconfirmed models-write-refused priority-invalid priority-unconfirmed priority-write-refused provider-not-allowed proxy-config-invalid proxy-down proxy-first-output-invalid proxy-init-failed proxy-key-invalid proxy-key-issue-failed proxy-key-refused proxy-key-revoking proxy-open proxy-revoke-revision-conflict proxy-rotate-grace-invalid proxy-uncertain thinking-unsupported",
|
||||
"R1/R3: owner-managed model and provider configuration",
|
||||
"Ask the owner to inspect the provider configuration through its supported controls; never put keys or callback contents in messages."
|
||||
);
|
||||
group(
|
||||
"block-recipient block-reply-required block-sender message-recipient-refused message-sender-refused tester-reply-required tester-thread-human user-reply-required user-thread-human",
|
||||
"Role messaging: permitted sender, recipient and reply context",
|
||||
"Read my_rails and use only a permitted target and the required reply context. Ask the owner if the permitted route is unavailable."
|
||||
);
|
||||
group(
|
||||
"command-not-granted credential-file credential-revoke-failed credential-unknown credential-leak gitea-reach-unknown gitea-reach-wider gitea-user-admin gitea-user-disabled gitea-user-foreign gitea-user-gone gitea-user-orphan grant-admin-missing grant-admin-nothing-left grant-admin-rotation-due grant-busy grant-chief-only grant-lost grant-revoke-failed grant-revoke-running grant-token-unknown grants-revoking remote-not-granted remote-host-mismatch remote-not-fork scope-changed",
|
||||
"R1/R8: scoped and revocable access",
|
||||
"Ask the owner to inspect the scoped access and binding state through the supported access controls; do not supply credentials in a call or widen your own grants."
|
||||
);
|
||||
group(
|
||||
"outcome-unknown outcome-unknown-revoked revision-conflict revision-conflict-revoked",
|
||||
"R2/R3: current revision and verified effect",
|
||||
"Read the latest status, revision and receipts before retrying. Verify whether an uncertain effect already happened, and use the current revision for any new attempt."
|
||||
);
|
||||
group(
|
||||
"confirm-required",
|
||||
"R2/R3: explicit confirmation of the current operation",
|
||||
"Read the current status and obtain the required confirmation through the supported human path."
|
||||
);
|
||||
group(
|
||||
"decision-closed decision-consumed decision-mismatch decision-not-approved decision-not-eligible decision-not-found decision-not-held decision-stale supersede-refused",
|
||||
"Role decisions: current, matching, approved and single-use decision",
|
||||
"Read the current decision, its route and its resolution and consumption receipts. Obtain the operation's matching approval from the permitted resolver before acting; a consumed approval cannot be reused."
|
||||
);
|
||||
group(
|
||||
"decision-not-allowed",
|
||||
"Role authority: no free-text decision channel",
|
||||
"Use the role's permitted message path to report the need to a person; do not use another decision channel."
|
||||
);
|
||||
group(
|
||||
"decision-required",
|
||||
"Role authority and R4: approval before acting",
|
||||
"Read my_rails. Raise a decision for the action and its permitted arbiter; hard waits go to a person. Use blocking false when there is no task."
|
||||
);
|
||||
group(
|
||||
"decision-route-refused",
|
||||
"Role messaging: decision close route",
|
||||
"Ask the owner to inspect the raiser and resolver messaging policy; do not route around the refused close path."
|
||||
);
|
||||
group(
|
||||
"definition-not-hireable instance-kept launch-not-configured no-chief-of-staff organization-busy organization-definition-refused organization-instance-referenced organization-instance-retired role-end-refused staff-request-refused",
|
||||
"Role configuration and protected instance controls",
|
||||
"Read the permitted configuration/status and ask the owner or configured chief to repair it through the supported path. Do not edit policy to bypass the refusal."
|
||||
);
|
||||
group(
|
||||
"field-writer task-already-archived task-archived task-conflict task-create-conflict task-create-uncertain task-not-archived task-not-found task-position-full task-project-refused task-scope-required task-subject-required",
|
||||
"Role task ownership, allowed fields and current task state",
|
||||
"Read the current task and assigned role. Use the permitted task operation with current evidence; ask the PM for scope or assignment changes through the decision path."
|
||||
);
|
||||
group(
|
||||
"task-required",
|
||||
"Role decisions: a blocking decision belongs to a task",
|
||||
"Supply the task_ref for this work, or raise with blocking false when there is no task."
|
||||
);
|
||||
group(
|
||||
"fork-foreign gitea-admin-unavailable gitea-failed gitea-not-configured gitea-not-declared host-busy host-changed host-creative-role host-enrolled host-failed host-in-use host-key-mismatch host-line-left host-not-configured host-not-declared host-not-enrolled host-not-granted host-not-ready host-remove-failed host-unknown publish-busy publish-unknown",
|
||||
"Role access: declared service, host or remote and verified outcome",
|
||||
"Check the supported access/status read and ask the owner to repair the declared service or grant. If an effect is uncertain, verify its receipt before retrying."
|
||||
);
|
||||
group(
|
||||
"human-required",
|
||||
"R4/R8: person-only operation",
|
||||
"A person must perform or approve this operation through its supported human path; an agent must not retry as a person."
|
||||
);
|
||||
group(
|
||||
"land-unknown no-push not-a-reviewer ref-moved self-review stale-commit verdict-exists verdict-not-approve verdict-not-found verdict-stale work-not-yours work-ref",
|
||||
"Role review and landing: independent verdict on the current work",
|
||||
"Read the latest work and review receipts. Obtain the required independent review or matching merge decision for the current commit before retrying."
|
||||
);
|
||||
group(
|
||||
"library-fetch-no-grant library-not-configured library-notice-store-failed library-write-no-grant library-write-refused web-failed web-not-configured web-not-granted",
|
||||
"Role tools: granted and configured web/library access",
|
||||
"Read my_rails and the permitted service status. Use only granted tools; ask the owner to repair unavailable service configuration."
|
||||
);
|
||||
group(
|
||||
"message-not-found no-subject not-assistant operation-not-found operation-not-open",
|
||||
"R7/role scope: visible object and allowed operation",
|
||||
"Use the caller's permitted reads to locate current context, then retry only the supported operation. Ask the owner if it remains unavailable."
|
||||
);
|
||||
group(
|
||||
"not-authorized",
|
||||
"Role authority: held action or approved decision",
|
||||
"Read my_rails and use the applicable decision path; ask the owner to inspect grants if no supported path exists."
|
||||
);
|
||||
group(
|
||||
"not-configured project-already-archived project-archived project-exists project-not-archived project-not-configured project-not-found project-record-invalid project-remote-configured project-remote-invalid project-remote-library project-remote-protected project-remote-unknown project-spec-human-only system-type-not-enabled tracker-not-store usage-not-configured",
|
||||
"R3/role scope: configured project and service state",
|
||||
"Read the supported project/service status and ask its owner to repair missing configuration or state before retrying."
|
||||
);
|
||||
group(
|
||||
"not-resolver",
|
||||
"Role resolve right and decision route",
|
||||
"Only the routed resolver holding the matching decision.resolve right may resolve. Read my_rails and notify that resolver."
|
||||
);
|
||||
group(
|
||||
"prd-arbiter-unavailable prd-authority-refused prd-changes-requested prd-decision-closed prd-not-approved prd-self-approval request-access-refused request-closed request-evidence-required request-expired request-failed request-not-configured request-not-found request-not-taken request-origin-refused request-project-refused request-revision-conflict request-selection-refused request-tracker-refused request-verification-refused requirement-snapshot-refused requirement-snapshot-required requirement-snapshot-revoked spec-unconfirmed stale-spec",
|
||||
"Role workflow: current admitted request and approved requirements",
|
||||
"Read the current request, requirements and decision state. Ask the responsible PM or routed arbiter to supply the missing current evidence before continuing."
|
||||
);
|
||||
group(
|
||||
"processing-audit-conflict processing-control-unsafe processing-uninitialized processing-generation-changed processing-halted processing-state-invalid storage-refused storage-unavailable",
|
||||
"R2/R3: available audited storage and processing state",
|
||||
"Stop the refused operation and report the processing or storage state to the owner. Resume only after the supported status shows the condition is repaired."
|
||||
);
|
||||
group(
|
||||
"read-only",
|
||||
"Role/session: read-only capability",
|
||||
"Use the permitted reads. Ask the owner or an authorized writer to perform the change."
|
||||
);
|
||||
group(
|
||||
"reserved-event",
|
||||
"R2: trusted append-only evidence",
|
||||
"Use the typed operation that records its own evidence; never emit a reserved event directly."
|
||||
);
|
||||
group(
|
||||
"role-not-chief",
|
||||
"R9: only the configured chief manages hires",
|
||||
"Send the staffing need to the configured chief through an allowed message path."
|
||||
);
|
||||
group(
|
||||
"unauthenticated",
|
||||
"R7: authenticated caller identity",
|
||||
"Ask the launcher to restore the caller's own authenticated session; never borrow another capability."
|
||||
);
|
||||
group(
|
||||
"request-timeout request-too-large incomplete-request invalid-response response-too-large unknown-tool tool-failed capacity-full",
|
||||
"R3/role tools: bounded protocol and available capacity",
|
||||
"Check the tool schema and narrow oversized requests. For a timeout, inspect status and receipts before retrying; never assume an uncertain operation did not happen."
|
||||
);
|
||||
// Finite error vocabularies forwarded by the project's, library's and request's
|
||||
// typed adapters are part of the same table, not an unhelpful unknown-code path.
|
||||
group(
|
||||
"project-busy library-sync-busy",
|
||||
"Role storage: exclusive write or sync already in progress",
|
||||
"Wait for the current operation to finish, read its status, then retry once the lock is clear."
|
||||
);
|
||||
group(
|
||||
"stale-base library-accept-stale",
|
||||
"R2/R3: current file or served revision",
|
||||
"Read the latest file or library status and its current revision. Reconcile the intended change before retrying with that base; never overwrite a newer change blindly."
|
||||
);
|
||||
group(
|
||||
"citation-cross-universe citation-invalid citation-missing citation-unavailable citation-unfetched citations-over-limit playground-cited-as-canon reply-invalid",
|
||||
"Role replies: validated citations, provenance and scope",
|
||||
"Use permitted library, spec or web reads and supply a valid reply with current citations in the allowed universe and scope. Narrow oversized citation lists; never invent provenance."
|
||||
);
|
||||
group(
|
||||
"invalid-request-admission invalid-request-effect invalid-request-event invalid-request-plan invalid-request-projects artifact-path-invalid artifact-type-refused",
|
||||
"R3: typed request plan, delivery and evidence schema",
|
||||
"Check the current request's tool schema and correct the plan, artifact path or evidence. Delivery requires the supported self-contained HTML artifact and matching verification evidence."
|
||||
);
|
||||
group(
|
||||
"prd-admission-revoked prd-author-invalid prd-candidate-invalid prd-candidate-required prd-candidate-size prd-candidate-stale prd-requirement-invalid",
|
||||
"Role PRD workflow: current candidate, authors and admission",
|
||||
"Read the current request, candidate, requirements, mode and approval. Ask the PM to correct or submit the current candidate through the supported workflow; never approve your own authorship."
|
||||
);
|
||||
group(
|
||||
"request-question-open request-question-closed request-question-mismatch request-phase-refused request-attachment-mismatch request-delivery-required request-document-refused",
|
||||
"Role request workflow: current phase, question and selected attachment",
|
||||
"Read intake_status and follow its current question, wait or delivery step. A pending question needs its answer before another question; use only the operation and attachment valid for the current phase."
|
||||
);
|
||||
group(
|
||||
"request-effect-conflict request-effect-unprepared request-mode-revision-conflict request-operation-conflict request-plan-binding-refused request-plan-path-collision request-plan-path-refused request-project-collision",
|
||||
"R2/R3: current request revision and bound operation",
|
||||
"Read the current request status, plan and operation receipts. Recover an existing operation before retrying; use a new operation ID only for a new intended operation, and never bypass a path or project collision."
|
||||
);
|
||||
group(
|
||||
"request-notification-unavailable request-services-unavailable library-credential-missing library-human-missing library-mapping-invalid library-problem-code library-remote-invalid library-state-invalid library-diverged library-accept-invalid",
|
||||
"R1/R3: owner-configured service, mapping and accepted library state",
|
||||
"Read the supported service or library status and ask the owner to repair the missing binding, mapping or accepted state. Check existing operation receipts before retrying an uncertain effect."
|
||||
);
|
||||
group(
|
||||
"library-commit library-git library-push-failed project-commit-uncertain project-init-cleanup",
|
||||
"R2/R3: verified repository effect and recovery",
|
||||
"Read current repository status and receipts to determine what already happened. Report an uncertain commit, push or cleanup to the owner before any new write; do not retry an uncertain effect blindly."
|
||||
);
|
||||
group(
|
||||
"project-author project-dirty project-git project-id project-invalid project-no-commit project-not-declared project-not-empty project-path project-read-commit project-read-content project-read-missing project-read-path project-record-path project-records-conflict project-records-missing project-remote project-repository tester-project-area",
|
||||
"Role project scope: declared repository, clean state and allowed paths",
|
||||
"Read the permitted project status and file metadata. Correct the declared project or allowed path when possible; ask the owner to repair repository state or identity. Preserve existing changes and never bypass repository or path checks."
|
||||
);
|
||||
group(
|
||||
"project-record-unchanged",
|
||||
"R2: no new record effect for unchanged content",
|
||||
"Read the current record and its existing commit receipt. If it already matches the intended content, use that evidence instead of retrying the same write."
|
||||
);
|
||||
group(
|
||||
"library-write-story-path library-revert-not-agent",
|
||||
"Role library writes: supporting paths and permitted agent-owned commits",
|
||||
"Use only the supporting paths and agent-owned commit reversals permitted by the library tool. Preserve story and canon files; ask the owner when the requested edit is outside that scope."
|
||||
);
|
||||
group(
|
||||
"query-argument-invalid query-chapter-ambiguous query-item-too-large query-universe-unknown",
|
||||
"Role library reads: known universe, unambiguous selection and bounded result",
|
||||
"Read the permitted library index, choose a known universe and an unambiguous book or chapter, and narrow the query or requested range before retrying."
|
||||
);
|
||||
export const REFUSALS = Object.freeze(Object.fromEntries(entries));
|
||||
const UNKNOWN = Object.freeze({ rail: "R3: fail closed", nextStep: "Report the refusal code to the responsible owner and inspect supported status; do not bypass the refusal." });
|
||||
export const refusalGuidance = code => Object.hasOwn(REFUSALS, code) ? REFUSALS[code] : UNKNOWN;
|
||||
@@ -9,11 +9,12 @@ const LIMIT = 65536;
|
||||
const PROJECT_SAVE_AT_START = new RegExp(`"verb":"${PROJECT_SAVE_VERB.replaceAll('.', '\\.')}"`);
|
||||
const LIBRARY_SAVE_AT_START = /"verb":"library\.file\.save"/;
|
||||
const SAVE_VERB_AT_START = { test: text => PROJECT_SAVE_AT_START.test(text) || LIBRARY_SAVE_AT_START.test(text) };
|
||||
// The refusal envelope. C2-06 (sage-3599): `reason` is added only when the error carries one, which
|
||||
// only explicitly scoped refusals do, so every other envelope is byte-identical to before.
|
||||
const refusal = (e, other) => e instanceof BusError
|
||||
? { ok: false, error: e.code, ...(typeof e.reason === 'string' ? { reason: e.reason } : {}) }
|
||||
: { ok: false, error: other };
|
||||
// Preserve every code and scoped reason, adding only static rail/next-step guidance.
|
||||
const refusal = (e, other) => {
|
||||
const error = e instanceof BusError ? e : new BusError(other);
|
||||
return { ok: false, error: error.code, rail: error.rail, nextStep: error.nextStep,
|
||||
...(typeof error.reason === 'string' ? { reason: error.reason } : {}) };
|
||||
};
|
||||
// One request per connection. There is deliberately no reconnect/retry or SQL verb.
|
||||
// `tasks` ({handle, timeout}) is the trusted S3 adapter; only the eight task verbs reach it.
|
||||
// `web` is the trusted web service (web.mjs), or a function from a business id to one (or null); only
|
||||
@@ -48,16 +49,16 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
|
||||
socket.end(JSON.stringify(result) + '\n');
|
||||
};
|
||||
socket.setTimeout(timeout, () => {
|
||||
finish({ ok: false, error: 'request-timeout' });
|
||||
finish(refusal(new BusError('request-timeout')));
|
||||
socket.destroySoon();
|
||||
});
|
||||
socket.on('data', async (chunk) => {
|
||||
if (done) return;
|
||||
if (processing) return finish({ ok: false, error: 'invalid-envelope' });
|
||||
if (processing) return finish(refusal(new BusError('invalid-envelope')));
|
||||
const total = input.length + chunk.length;
|
||||
if (total > LIMIT && !(total <= PROJECT_SAVE_REQUEST_MAX &&
|
||||
SAVE_VERB_AT_START.test(Buffer.concat([input, chunk]).subarray(0, 1024).toString('latin1')))) {
|
||||
finish({ ok: false, error: 'request-too-large' });
|
||||
finish(refusal(new BusError('request-too-large')));
|
||||
return;
|
||||
}
|
||||
input = Buffer.concat([input, chunk]);
|
||||
@@ -187,7 +188,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
|
||||
}
|
||||
});
|
||||
socket.on('end', () => {
|
||||
if (!done && !processing) finish({ ok: false, error: 'incomplete-request' });
|
||||
if (!done && !processing) finish(refusal(new BusError('incomplete-request')));
|
||||
});
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
|
||||
@@ -282,7 +282,7 @@ test('REQ-LIMIT-1: the chief of staff is logged as allowed, and organization.set
|
||||
const outside = [
|
||||
{ launch: { by: 'coder' } },
|
||||
{ human: 'mallory' },
|
||||
{ arbiters: { technical: 'coder', delivery: 'coder' } },
|
||||
{ arbiters: { technical: 'chief', delivery: 'chief' } },
|
||||
{ roles: {} },
|
||||
{ actor: 'jason' },
|
||||
{ 'launch.by': 'coder' },
|
||||
@@ -1026,7 +1026,7 @@ test('row 194 T8, T9, T18: through the runtime the chief hires and fires with th
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-chief-hire-'));
|
||||
const rolesDir = rolesCopy(root);
|
||||
// A hireable scratch definition: version 2, no credentials, every action on HIRE_ALLOWED.
|
||||
writeFileSync(join(rolesDir, 'aide.md'), '# Aide\n');
|
||||
writeFileSync(join(rolesDir, 'aide.md'), '# Aide\n\n## Authority\n');
|
||||
writeJson(join(rolesDir, 'aide.json'), {
|
||||
roleVersion: 2, name: 'aide', title: 'Aide', contract: 'aide.md', tools: ['read'], network: 'api-only',
|
||||
authority: { withinRole: ['message.send'], crossRole: [] }, credentials: [],
|
||||
|
||||
@@ -4,6 +4,7 @@ import { connect } from 'node:net';
|
||||
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { Client } from '../src/client.mjs';
|
||||
import { refusalGuidance } from '../src/refusals.mjs';
|
||||
import { serve } from '../src/server.mjs';
|
||||
import { FIXED_TEXTS, GuardError } from '../../library/src/guard.mjs';
|
||||
import { git } from '../../library/tests/helpers.mjs';
|
||||
@@ -138,7 +139,7 @@ test('C2-06 page store is session-local, caps 20 fetches and 200 KiB, drops at d
|
||||
await assert.rejects(f.call(replacement, 'message.send', { to: 'human', body: citedBody([{ url: 'https://fixture.invalid/21', quote: 'xxxx' }]) }), { code: 'citation-unfetched' });
|
||||
});
|
||||
|
||||
test('C2-06 transport passes safe capped guard reason; ordinary refusal envelope is byte-identical', async t => {
|
||||
test('C2-06 transport passes safe capped guard reason; ordinary refusal retains its code and adds static rail guidance', async t => {
|
||||
const f = citationWorld(t, { guard: async () => { throw new GuardError('citation-invalid', 'guard reason '.repeat(50)); } });
|
||||
f.call(f.agent, 'role.claim'); const path = join(f.root, 'socket');
|
||||
const server = await serve({ broker: f.broker, path }); t.after(() => server.close());
|
||||
@@ -148,7 +149,7 @@ test('C2-06 transport passes safe capped guard reason; ordinary refusal envelope
|
||||
const socket = connect(path); let text = ''; socket.on('error', reject); socket.on('data', b => { text += b; }); socket.on('end', () => resolve(text));
|
||||
socket.on('connect', () => socket.write(JSON.stringify({ cap: f.agent, verb: 'unknown-fixture' }) + '\n'));
|
||||
});
|
||||
assert.equal(raw, '{"ok":false,"error":"unknown-verb"}\n');
|
||||
assert.deepEqual(JSON.parse(raw), { ok: false, error: 'unknown-verb', ...refusalGuidance('unknown-verb') });
|
||||
});
|
||||
|
||||
test('C2-06 non-library businesses retain role.claim shape and unguarded free sends', async t => {
|
||||
|
||||
@@ -18,8 +18,8 @@ export function citationWorld(t, { definition = 'coder', library = true, guard:
|
||||
writeFileSync(settings, JSON.stringify({ replyLimits: { requireCitation: false, citationsPerReply: 20 } }));
|
||||
const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true },
|
||||
...(library ? { projects: { books: map } } : {}),
|
||||
arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { definition, web: true, authority: { withinRole: sendAuthority ? ['message.send'] : [], crossRole: [] } } } };
|
||||
arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition, web: true, authority: { withinRole: sendAuthority ? ['message.send'] : [], crossRole: [] } } } };
|
||||
const limits = () => {
|
||||
try { return resolveReplyLimits({ readable: true, document: JSON.parse(readFileSync(settings, 'utf8')) }); }
|
||||
catch { return resolveReplyLimits({ readable: false }); }
|
||||
|
||||
@@ -18,8 +18,8 @@ test('C2-06 runtime reads changed and unreadable reply limits without restarting
|
||||
// Runtime takes the host's broker projection, not the business validator's unresolved role entries.
|
||||
// No agent action is needed here: use the same empty-authority shape as limits-runtime.test.mjs.
|
||||
const business = { id: 'acme', human: 'jason', projects: checked.projects,
|
||||
arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
|
||||
arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
|
||||
const runtime = await startBroker({ dataRoot, businesses: { acme: business }, organization: { dir, rolesDir, system: systemFor(root) } });
|
||||
t.after(() => runtime.close());
|
||||
const human = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
|
||||
@@ -48,8 +48,8 @@ function setup(t) {
|
||||
human: 'jason',
|
||||
arbiters: { technical: 'pm', delivery: 'pm' },
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
pm: { definition: 'pm', authority: { withinRole: ['message.send', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
},
|
||||
vars: { 'bus.tester': true },
|
||||
};
|
||||
@@ -227,7 +227,7 @@ test('ordinary PRD approval and nine non-yes/no choices use the same protocol wi
|
||||
test('ordinary role routes refuse Console resolution and agent answers retain the actual role/run', (t) => {
|
||||
const f = setup(t);
|
||||
for (const route of ['pm', 'chief']) {
|
||||
const id = grantDecision(f.store, { action: 'prd.approve', route, close: null });
|
||||
const id = grantDecision(f.store, { action: 'policy.change', route, close: null });
|
||||
refusesSync(() => f.call('console', 'decision.preview.console', { id }), 'decision-not-eligible');
|
||||
refusesSync(() => f.call('console', 'decision.resolve.console', { id, choice: 'yes', digest: '0'.repeat(64) }), 'decision-not-eligible');
|
||||
f.call(route, 'decision.resolve', { id, choice: 'yes' });
|
||||
|
||||
@@ -30,8 +30,8 @@ async function setup(t, { storeRoot = true } = {}) {
|
||||
const store = (agent, service) => join(root, 'secrets', 'agents', 'demo', agent, service);
|
||||
// The bus's resolved business carries the same references; a store one names its derived file.
|
||||
const demo = {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: { 'bus.tester': true },
|
||||
roles: {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
coder: { ...role, credentials: { gitea: { store: true, file: store('coder', 'gitea'), rotateBy: DATE }, vikunja: doc.roles.coder.credentials.vikunja } },
|
||||
reviewer: { ...role, credentials: doc.roles.reviewer.credentials },
|
||||
},
|
||||
@@ -58,7 +58,7 @@ test('row 199: the lifecycle verbs refuse every session but the human, and the t
|
||||
const calls = [];
|
||||
const b = new Broker({
|
||||
store: s,
|
||||
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: { 'bus.tester': true }, roles: { coder: role } } },
|
||||
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: role } } },
|
||||
credentialVerbs: (id, verb, args, actor) => (calls.push([verb, actor]), { ok: true }),
|
||||
});
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
@@ -97,7 +97,7 @@ test('row 199: the lifecycle verbs refuse every session but the human, and the t
|
||||
b.request(tester, { verb: 'credentials.revoke', args: args['credentials.revoke'] });
|
||||
assert.deepEqual(calls, [['credentials.issue', 'jason'], ['credentials.revoke', 'jason'], ['credentials.delete', 'jason'], ['credentials.revoke', 'tester']]);
|
||||
// No lifecycle without the runtime's store.
|
||||
const bare = new Broker({ store: s, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: role } } } });
|
||||
const bare = new Broker({ store: s, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: role } } } });
|
||||
const h2 = bare.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
assert.throws(() => bare.request(h2, { verb: 'credentials.revoke', args: args['credentials.revoke'] }), /not-configured/);
|
||||
});
|
||||
@@ -182,8 +182,8 @@ test('#1886: a store root that exists with loose modes still refuses start', asy
|
||||
mkdirSync(join(root, 'secrets', 'agents'), { recursive: true, mode: 0o750 });
|
||||
chmodSync(join(root, 'secrets', 'agents'), 0o750);
|
||||
const demo = {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: {},
|
||||
roles: { coder: { ...role, credentials: { gitea: { store: true, file: join(root, 'secrets', 'agents', 'demo', 'coder', 'gitea'), rotateBy: DATE } } } },
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: {},
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { ...role, credentials: { gitea: { store: true, file: join(root, 'secrets', 'agents', 'demo', 'coder', 'gitea'), rotateBy: DATE } } } },
|
||||
};
|
||||
await assert.rejects(startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } }), /credential-file/);
|
||||
});
|
||||
@@ -24,8 +24,8 @@ async function setup(t, limit = 0) {
|
||||
writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'),
|
||||
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: ROLES, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } },
|
||||
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, ...ROLES }, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } },
|
||||
organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
return { bus, human: bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) };
|
||||
@@ -76,8 +76,8 @@ test('REQ-LIMIT-4 bindLaunch keeps the account off session.launched, refuses one
|
||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-daily-bind-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const broker = new Broker({ store, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: ROLES, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } } });
|
||||
const broker = new Broker({ store, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, ...ROLES }, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } } });
|
||||
const base = { business: 'demo', role: 'coder', harness: 'claude-code', provider: 'anthropic', model: 'opus' };
|
||||
for (const [i, account] of ['Jason Max', '', 5, {}, '-x'].entries())
|
||||
assert.throws(() => broker.bindLaunch({ ...base, run: `bad${i}`, account }), { code: 'invalid-request' }, JSON.stringify(account));
|
||||
|
||||
@@ -13,8 +13,8 @@ const options = [
|
||||
{ key: 'no', text: 'Decline' },
|
||||
];
|
||||
const roles = {
|
||||
pm: { authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
cto: { authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
pm: { authority: { withinRole: ['decision.resolve.delivery', 'message.send'], crossRole: [] } },
|
||||
cto: { authority: { withinRole: ['decision.resolve.technical', 'message.send'], crossRole: [] } },
|
||||
coder: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } },
|
||||
};
|
||||
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles } };
|
||||
|
||||
@@ -22,8 +22,8 @@ const options = [
|
||||
];
|
||||
const pusher = { authority: { withinRole: ['message.send'], crossRole: ['git.push.working'] } };
|
||||
const roles = {
|
||||
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
pm: { definition: 'pm', authority: { withinRole: ['message.send', 'decision.resolve.technical'], crossRole: [] } },
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
// Hears from pm, the technical arbiter, but not from the chief of staff, the delivery arbiter.
|
||||
coder: { definition: 'coder', ...pusher },
|
||||
|
||||
@@ -13,8 +13,8 @@ const options = [
|
||||
{ key: 'no', text: 'Decline' },
|
||||
];
|
||||
const roles = {
|
||||
pm: { authority: { withinRole: ['message.send', 'task.create'], crossRole: [] } },
|
||||
cto: { authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
pm: { authority: { withinRole: ['decision.resolve.delivery', 'message.send', 'task.create'], crossRole: [] } },
|
||||
cto: { authority: { withinRole: ['decision.resolve.technical', 'message.send'], crossRole: [] } },
|
||||
coder: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } },
|
||||
};
|
||||
const businesses = {
|
||||
|
||||
@@ -48,7 +48,7 @@ function setup(t, { keygen, check, hang = false } = {}) {
|
||||
arbiters: { technical: 'pm', delivery: 'pm' },
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
pm: { definition: 'pm', authority: { withinRole: ['message.send', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
},
|
||||
vars: { 'bus.tester': true },
|
||||
};
|
||||
|
||||
@@ -17,8 +17,8 @@ test('row 298 runtime: human scope approval writes the file and organization his
|
||||
const file = writeJson(join(root, 'businesses/demo.json'), businessDoc(root, 'demo'));
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
runtime = await startBroker({ dataRoot: join(root, 'data'),
|
||||
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } },
|
||||
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } },
|
||||
organization: { dir: root, rolesDir, system: systemFor(root) },
|
||||
});
|
||||
const human = runtime.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
|
||||
@@ -27,8 +27,8 @@ test('REQ-WAIT-2: a business granting a hard wait at either class is refused; th
|
||||
const root = mkdtempSync(join(tmpdir(), 'hard-waits-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const business = authority => ({ demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { definition: 'coder', authority } } } });
|
||||
const business = authority => ({ demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority } } } });
|
||||
for (const action of WAITS) {
|
||||
assert.throws(() => new Broker({ store, businesses: business({ withinRole: [action], crossRole: [] }) }), /invalid-authority/, action);
|
||||
assert.throws(() => new Broker({ store, businesses: business({ withinRole: [], crossRole: [action] }) }), /invalid-authority/, action);
|
||||
@@ -54,19 +54,19 @@ test('REQ-DEC-2: every hard wait goes to the human, cited or not, and the arbite
|
||||
}
|
||||
}
|
||||
const d = await f.raise({ action: 'data.delete', ...cite(f) });
|
||||
assert.throws(() => f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/);
|
||||
assert.throws(() => f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/);
|
||||
f.call(f.human, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
});
|
||||
|
||||
test('REQ-DEC-2: a gated raise citing the current spec goes to the arbiter for its domain; uncited, to the human', async t => {
|
||||
const f = await fixture(t, { arbiters: { technical: 'cto', delivery: 'reviewer' },
|
||||
roles: { cto: { definition: 'cto', authority: { withinRole: ['message.send'], crossRole: [] } } } });
|
||||
const f = await fixture(t, { arbiters: { technical: 'cto', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, cto: { definition: 'cto', authority: { withinRole: ['message.send', 'decision.resolve.technical'], crossRole: [] } } } });
|
||||
f.agent('cto');
|
||||
for (const action of LEFT) {
|
||||
const raise = { action, ...(action === 'role.revoke' ? { target: 'reviewer' } : {}) };
|
||||
assert.deepEqual(routed(f, f.raise(raise)), { class: 'gated', route_to: 'human' }, action);
|
||||
assert.deepEqual(routed(f, await f.raise({ ...raise, ...cite(f), domain: 'technical' })), { class: 'gated', route_to: 'cto' }, action);
|
||||
assert.deepEqual(routed(f, await f.raise({ ...raise, ...cite(f) })), { class: 'gated', route_to: 'reviewer' }, action);
|
||||
assert.deepEqual(routed(f, await f.raise({ ...raise, ...cite(f), domain: 'technical' })), { class: 'gated', route_to: action === 'policy.change' ? 'chief' : 'cto' }, action);
|
||||
assert.deepEqual(routed(f, await f.raise({ ...raise, ...cite(f) })), { class: 'gated', route_to: action === 'git.merge.protected' ? 'cto' : 'chief' }, action);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -74,15 +74,15 @@ test('REQ-DEC-2: an arbiter-approved cited gated decision authorizes the raiser,
|
||||
const f = await fixture(t, { roles: { pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } } } });
|
||||
f.agent('pm');
|
||||
const d = await f.raise({ action: 'role.revoke', target: 'pm', ...cite(f) });
|
||||
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'reviewer' });
|
||||
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'chief' });
|
||||
assert.throws(() => f.call(f.human, 'decision.resolve', { id: d.id, choice: 'yes' }), /agent-required/);
|
||||
f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
assert.deepEqual(f.call(f.coder, 'role.revoke', { role: 'pm', decision: d.id }), { revoked: true });
|
||||
});
|
||||
|
||||
test('REQ-DEC-2: a cited merge raised by merge-gate goes to the technical arbiter; a merge raised without the grant routes by citation', async t => {
|
||||
const f = await fixture(t, { arbiters: { technical: 'cto', delivery: 'reviewer' }, roles: {
|
||||
cto: { definition: 'cto', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
const f = await fixture(t, { arbiters: { technical: 'cto', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
cto: { definition: 'cto', authority: { withinRole: ['message.send', 'decision.resolve.technical'], crossRole: [] } },
|
||||
'merge-gate': { definition: 'merge-gate', authority: { withinRole: [], crossRole: ['git.merge.protected'] } },
|
||||
} });
|
||||
const cto = f.agent('cto'), gate = f.agent('merge-gate');
|
||||
@@ -106,7 +106,7 @@ test('REQ-DEC-2: a stale or unconfirmed citation refuses without a decision; an
|
||||
await assert.rejects(f.raise({ action: 'policy.change', ...old, spec_commit: edit }), { code: 'spec-unconfirmed' });
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, 0);
|
||||
const current = f.save('# Current\n');
|
||||
const own = await f.call(f.reviewer, 'decision.raise', { action: 'policy.change', question: 'Change?',
|
||||
const own = await f.call(f.chief, 'decision.raise', { action: 'policy.change', question: 'Change?',
|
||||
options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false,
|
||||
project: 'stack', spec_path: 'spec/north-star.md', spec_commit: current });
|
||||
assert.deepEqual(routed(f, own), { class: 'gated', route_to: 'human' });
|
||||
@@ -136,7 +136,7 @@ const allowedCount = f => f.store.get("SELECT count(*) n FROM events WHERE kind=
|
||||
test('Q169: the chief of staff\'s end of the assistant stops every thread run; its own run and another ender\'s are refused', async t => {
|
||||
const f = await fixture(t, { roles: { ...seats,
|
||||
chief: { definition: 'chief-of-staff', ...grants(['role.end']) }, lead: { definition: 'lead', ...grants(['role.end']) } } });
|
||||
const chief = f.agent('chief'), lead = f.agent('lead');
|
||||
const chief = f.chief, lead = f.agent('lead');
|
||||
// No thread is held yet, so there is nothing to end.
|
||||
assert.throws(() => f.call(chief, 'role.revoke', { role: 'assistant', reason: 'Q169' }), /not-held/);
|
||||
const a = held(f, 'a-run'), b = held(f, 'b-run');
|
||||
@@ -158,11 +158,11 @@ test('Q169: the chief of staff\'s end of the assistant stops every thread run; i
|
||||
|
||||
test('Q169: a revoke of the assistant the chief of staff raises goes to the human, cited or not, and no arbiter resolves it', async t => {
|
||||
const f = await fixture(t, { roles: { ...seats, chief: { definition: 'chief-of-staff', ...grants(['role.end']) } } });
|
||||
const chief = f.agent('chief');
|
||||
const chief = f.chief;
|
||||
for (const args of [{}, cite(f), { ...cite(f), domain: 'technical' }, { ...cite(f), domain: 'delivery' }]) {
|
||||
const d = await revoke(f, chief, 'assistant', args);
|
||||
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'human' }, JSON.stringify(args));
|
||||
assert.throws(() => f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/);
|
||||
assert.throws(() => f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/);
|
||||
}
|
||||
// Resolved by the human, the chief's revoke citing it stops the thread run, once.
|
||||
const a = held(f, 'a-run');
|
||||
@@ -181,8 +181,8 @@ test('Q169: another role\'s cited revoke of the assistant, resolved by an arbite
|
||||
// One raised while the instance's definition wasn't protected went to the arbiter. The same
|
||||
// store under a business that now makes it the assistant still refuses it.
|
||||
const d = await revoke(f, f.coder, 'pm', cite(f));
|
||||
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'reviewer' });
|
||||
f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'chief' });
|
||||
f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
const now = { ...f.business, roles: { ...f.business.roles, pm: { definition: 'assistant', ...grants([]) } } };
|
||||
const later = new Broker({ store: f.store, businesses: { demo: now } });
|
||||
const coder = later.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi', address: 'coder' });
|
||||
@@ -192,7 +192,7 @@ test('Q169: another role\'s cited revoke of the assistant, resolved by an arbite
|
||||
test('Q169: a revoke of the assistant citing a decision the human resolved stops its thread run', async t => {
|
||||
const f = await fixture(t, { roles: seats });
|
||||
const d = await revoke(f, f.coder, 'assistant', cite(f));
|
||||
assert.throws(() => f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/);
|
||||
assert.throws(() => f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/);
|
||||
f.call(f.human, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
// Row 155: the recorded holder run (null) and the role_claims one (none) compare equal, so the
|
||||
// revoke reaches the thread claims and stops the held one.
|
||||
@@ -205,8 +205,8 @@ test('Q169: a cited revoke of a role not in END_PROTECTED still goes to the arbi
|
||||
const f = await fixture(t, { roles: seats });
|
||||
f.agent('pm');
|
||||
const d = await revoke(f, f.coder, 'pm', cite(f));
|
||||
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'reviewer' });
|
||||
f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'chief' });
|
||||
f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
assert.deepEqual(f.call(f.coder, 'role.revoke', { role: 'pm', decision: d.id }), { revoked: true });
|
||||
});
|
||||
|
||||
@@ -216,7 +216,7 @@ test('Q169: a role granted role.revoke at either class still sends a revoke of t
|
||||
boss: { definition: 'boss', ...grants(['role.revoke']) } } });
|
||||
const lead = f.agent('lead'), boss = f.agent('boss');
|
||||
assert.deepEqual(routed(f, await revoke(f, lead, 'assistant', cite(f))), { class: 'gated', route_to: 'human' });
|
||||
assert.deepEqual(routed(f, await revoke(f, lead, 'pm', cite(f))), { class: 'cross-role', route_to: 'reviewer' });
|
||||
assert.deepEqual(routed(f, await revoke(f, lead, 'pm', cite(f))), { class: 'cross-role', route_to: 'chief' });
|
||||
const own = await revoke(f, boss, 'assistant', { choice: 'yes' });
|
||||
assert.deepEqual(routed(f, own), { class: 'gated', route_to: 'human' });
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decision_events WHERE decision=?', own.id).n, 0);
|
||||
|
||||
@@ -12,7 +12,7 @@ export const HUMAN_YES = { op: 'resolved', by: 'jason', choice: 'yes', via: 'cli
|
||||
|
||||
// A credential.mint decision with `grant` in its raise body. `close` is the decision_events row, or null
|
||||
// for an open decision.
|
||||
export function grantDecision(store, { business = 'demo', action = 'credential.mint', route = 'human', grant, close = HUMAN_YES }) {
|
||||
export function grantDecision(store, { business = 'demo', action = 'credential.mint', route = 'human', domain = 'delivery', grant, close = HUMAN_YES }) {
|
||||
const id = randomUUID();
|
||||
store.run(
|
||||
'INSERT INTO decisions(id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
@@ -21,7 +21,7 @@ export function grantDecision(store, { business = 'demo', action = 'credential.m
|
||||
store.run(
|
||||
"INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,'action.allowed',?,?,?,?)",
|
||||
randomUUID(), AT, business, 'chief', 'chief-run', id,
|
||||
JSON.stringify({ decision: id, action, operation: 'decision.raise', approvalChoice: 'yes', ...(grant ? { grant } : {}) }),
|
||||
JSON.stringify({ decision: id, action, operation: 'decision.raise', ...(domain === null ? {} : { domain }), approvalChoice: 'yes', ...(grant ? { grant } : {}) }),
|
||||
);
|
||||
if (close)
|
||||
store.run('INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)', id, AT, close.op, close.by, close.choice ?? null, close.via ?? null);
|
||||
|
||||
@@ -572,8 +572,8 @@ test('T27: the bus start sweeps <configDir>/secrets/run before it listens, and a
|
||||
await new Promise((r) => dead.on('close', r));
|
||||
writeFileSync(join(run, 'h-dddddd', 'pids'), `${dead.pid} x\n`);
|
||||
const demo = {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { authority: { withinRole: ['message.send'], crossRole: [] }, credentials: {} } },
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: ['message.send'], crossRole: [] }, credentials: {} } },
|
||||
};
|
||||
const start = () => startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
const bus = await start();
|
||||
@@ -640,8 +640,8 @@ test('M3: a host_grants record gives Credentials a store reference at start, and
|
||||
mkdirSync(join(root, 'secrets', 'agents'), { recursive: true, mode: 0o700 });
|
||||
chmodSync(join(root, 'secrets'), 0o700);
|
||||
const demo = {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { authority: { withinRole: ['message.send'], crossRole: [] }, credentials: {} } },
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: ['message.send'], crossRole: [] }, credentials: {} } },
|
||||
};
|
||||
const start = () => startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
const hostKeys = (bus) => bus.credentials.status().filter((c) => c.service.startsWith('host.'));
|
||||
|
||||
@@ -65,7 +65,7 @@ test('row 165 runtime: an add and a change reach the organization file with the
|
||||
const rolesDir = rolesCopy(root);
|
||||
writeJson(join(root, 'businesses/demo.json'), businessDoc(root, 'demo'));
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const file = join(root, 'businesses/demo.json');
|
||||
|
||||
@@ -26,7 +26,7 @@ const A = 'a'.repeat(40);
|
||||
const B = 'b'.repeat(40);
|
||||
const C = 'c'.repeat(40);
|
||||
const roles = {
|
||||
pm: { authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
pm: { authority: { withinRole: ['message.send', 'decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
coder: { authority: { withinRole: ['git.push.working', 'review.request', 'message.send'], crossRole: [] } },
|
||||
reviewer: { authority: { withinRole: ['review.verdict', 'message.send'], crossRole: [] } },
|
||||
'merge-gate': { authority: { withinRole: [], crossRole: [MERGE] } },
|
||||
@@ -221,15 +221,15 @@ test('REQ-LAND-1: a within-role merge grant refuses not-authorized at the land',
|
||||
assert.deepEqual([...w.merges(), ...w.events('action.refused')], []);
|
||||
});
|
||||
|
||||
test('a cross-role merge that routes to the human, because merge-gate is its own arbiter, lands on the human\'s CLI answer', (t) => {
|
||||
const w = setup(t, { arbiters: { technical: 'merge-gate', delivery: 'merge-gate' } });
|
||||
test('a cross-role merge that routes to the human, because the cto lander is its own arbiter, lands on the human\'s CLI answer', (t) => {
|
||||
const w = setup(t, { arbiters: { technical: 'cto', delivery: 'pm' }, roles: { ...roles, cto: { authority: { withinRole: ['decision.resolve.technical'], crossRole: [MERGE] } } } });
|
||||
w.push(A);
|
||||
w.approve(A);
|
||||
const d = w.raise();
|
||||
const d = w.raise('cto');
|
||||
assert.deepEqual(w.routed(d), { class: 'cross-role', route_to: 'human' });
|
||||
assert.throws(() => w.resolve(d), /human-required/);
|
||||
w.resolve(d, 'yes', 'human');
|
||||
const { ticket, by, via } = w.check({ task_ref: TASK, decision: d.id });
|
||||
const { ticket, by, via } = w.b.checkLand(w.caps.cto, { task_ref: TASK, decision: d.id });
|
||||
assert.deepEqual([by, via], ['jason', 'cli']);
|
||||
w.b.recordLand(ticket, w.land);
|
||||
assert.equal(w.merges()[0].body.class, 'cross-role');
|
||||
|
||||
@@ -119,7 +119,7 @@ const TASK = 'vikunja:1/2';
|
||||
|
||||
test('row 302 a run landRelease released answers launcher-released, and claims nothing again', (t) => {
|
||||
const w = setup(t, {
|
||||
pm: { authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
pm: { authority: { withinRole: ['message.send', 'decision.resolve.technical'], crossRole: [] } },
|
||||
coder: { authority: { withinRole: ['git.push.working', 'review.request', 'message.send'], crossRole: [] } },
|
||||
reviewer: { authority: { withinRole: ['review.verdict', 'message.send'], crossRole: [] } },
|
||||
'merge-gate': { authority: { withinRole: [], crossRole: [MERGE] } },
|
||||
|
||||
@@ -43,9 +43,9 @@ async function until(fn, ms = 10000) {
|
||||
}
|
||||
|
||||
const makeBusiness = ({ root, chief = true, instances = null } = {}) => ({
|
||||
id: 'acme', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
id: 'acme', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' },
|
||||
// sage-4097 / mos-1584: the chief's declared read credential; no-chief negative fixture stays absent.
|
||||
roles: { ...(chief ? { chief: { definition: 'chief-of-staff', ...role, credentials: { gitea: giteaReference(join(root, 'secrets', 'agents'), 'acme', 'chief', '2099-01-01') } } } : {}), coder: { definition: 'coder', ...role } },
|
||||
roles: { cto: { definition: 'cto', authority: { withinRole: ['decision.resolve.technical'], crossRole: [] } }, pm: { definition: 'pm', authority: { withinRole: ['decision.resolve.delivery'], crossRole: [] } }, ...(chief ? { chief: { definition: 'chief-of-staff', ...role, credentials: { gitea: giteaReference(join(root, 'secrets', 'agents'), 'acme', 'chief', '2099-01-01') } } } : {}), coder: { definition: 'coder', ...role } },
|
||||
projects: { books: { ...mappingEntry(), user: 'books-bot' } },
|
||||
...(instances ? { launch: { instances } } : {}),
|
||||
});
|
||||
@@ -77,7 +77,7 @@ function world(t, options) {
|
||||
// chief-of-staff instance, fired by boss. The real read grant targets the library project books.
|
||||
function verbDoc(url, root) {
|
||||
return {
|
||||
id: 'acme', human: 'jason', arbiters: { technical: 'boss', delivery: 'boss' },
|
||||
id: 'acme', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {
|
||||
boss: { definition: 'pm', authority: chiefAuthority },
|
||||
// sage-4097 / mos-1584: fixture projection carries the chief's own Gitea store reference.
|
||||
|
||||
@@ -31,8 +31,8 @@ async function world(t, { projects, adapter, claimed = true, now = () => NOW, fi
|
||||
const f = scratchRepo(t, files), dataRoot = join(f.root, 'data');
|
||||
mkdirSync(dataRoot);
|
||||
const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true },
|
||||
projects: projects ?? { books: mappingEntry() }, arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
|
||||
projects: projects ?? { books: mappingEntry() }, arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
|
||||
const context = { business, project: 'books', dataRoot };
|
||||
let mirror = null, state = null, fetches = 0;
|
||||
if (business.projects.books?.kind === 'library') {
|
||||
@@ -222,8 +222,8 @@ test('real socket awaits lookup results and rechecks rejected query arguments',
|
||||
test('runtime actually injects the lookup adapter, without exposing human library.file', { timeout: 30_000 }, async t => {
|
||||
const f = scratchRepo(t, TEXTS), dataRoot = join(f.root, 'runtime'); mkdirSync(dataRoot);
|
||||
const business = { id: 'acme', human: 'jason', plugins: { harness: ['pi'] }, projects: { books: mappingEntry() },
|
||||
arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
|
||||
arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
|
||||
const runtime = await startBroker({ dataRoot, businesses: { acme: business } }); t.after(() => runtime.close());
|
||||
const mirror = library.mirrorPath({ business, project: 'books', dataRoot });
|
||||
git(f.repo, '-c', 'protocol.file.allow=always', 'push', '-q', mirror, 'HEAD:refs/heads/served');
|
||||
|
||||
@@ -21,7 +21,7 @@ function world(t, adapter) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-library-sync-')), store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true },
|
||||
projects: { books: { ...mappingEntry(), user: 'books-bot' } }, arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } };
|
||||
projects: { books: { ...mappingEntry(), user: 'books-bot' } }, arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } };
|
||||
const broker = new Broker({ store, businesses: { acme: business }, library: adapter });
|
||||
const human = broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const agent = broker.bindLaunch({ business: 'acme', role: 'coder', run: 'library-sync-agent', harness: 'pi' });
|
||||
@@ -81,7 +81,7 @@ test('C2-03a runtime: the timer syncs each entry with a user at start and on eac
|
||||
mkdirSync(dataRoot, { mode: 0o700 });
|
||||
mkdirSync(join(configDir, 'secrets', 'acme'), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(join(configDir, 'secrets', 'acme', library.SLOT), TOKEN + '\n', { mode: 0o600 });
|
||||
const business = { id: 'acme', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } },
|
||||
const business = { id: 'acme', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } },
|
||||
projects: { books: { ...mappingEntry(), user: 'books-bot' }, quiet: mappingEntry() } };
|
||||
const calls = [];
|
||||
// A git that succeeds without fetching: the remote-tracking ref stays absent, so each pass fails.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Row 288: broker authority, exact notice and strict Console revert API. Fixture grant rows only.
|
||||
import { refusalGuidance } from '../src/refusals.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
||||
@@ -180,7 +181,7 @@ test('C2-02 socket: a save name nested in proof cannot enlarge an agent envelope
|
||||
socket.on('error', reject); socket.on('connect', () => socket.write(request));
|
||||
socket.on('data', bytes => { result += bytes; if (result.includes('\n')) { socket.destroy(); resolve(JSON.parse(result)); } });
|
||||
});
|
||||
assert.deepEqual(response, { ok: false, error: 'request-too-large' });
|
||||
assert.deepEqual(response, { ok: false, error: 'request-too-large', ...refusalGuidance('request-too-large') });
|
||||
assert.equal(f.seen.length, 0);
|
||||
});
|
||||
|
||||
@@ -234,8 +235,8 @@ test('C2-02 F2/E6 runtime: human saves/reverts use editor.user; no login never u
|
||||
const pushes = [];
|
||||
const command = (kind, url, options) => { if (kind === 'push') pushes.push(options.user); return bare.command(kind, url, options); };
|
||||
const initial = (await bare.state()).lastFetch;
|
||||
const business = { ...bare.context.business, human: 'jason', roles: { coder: { authority: { withinRole: [], crossRole: [] } } },
|
||||
arbiters: { technical: 'coder', delivery: 'coder' } };
|
||||
const business = { ...bare.context.business, human: 'jason', roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } },
|
||||
arbiters: { technical: 'chief', delivery: 'chief' } };
|
||||
const runtime = await startBroker({ dataRoot: bare.context.dataRoot, businesses: { acme: business },
|
||||
librarySync: { configDir, command, intervalMs: 60000 } });
|
||||
try {
|
||||
|
||||
@@ -16,7 +16,7 @@ function world(t, adapter = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-library-')), store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true },
|
||||
projects: { books: mappingEntry() }, arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } };
|
||||
projects: { books: mappingEntry() }, arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } };
|
||||
const broker = new Broker({ store, businesses: { acme: business }, library: adapter });
|
||||
const human = broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const agent = broker.bindLaunch({ business: 'acme', role: 'coder', run: 'library-agent', harness: 'pi' });
|
||||
@@ -68,7 +68,7 @@ test('C2-04 runtime: protectedRoots skips library but retains real code-root cre
|
||||
const f = repo(t), dataRoot = join(f.root, 'data'), token = join(f.root, 'fixture-token');
|
||||
mkdirSync(dataRoot, { mode: 0o700 }); writeFileSync(token, 'made-up-fixture-token-123456', { mode: 0o600 });
|
||||
const business = { id: 'acme', human: 'jason', projects: { books: mappingEntry(), stack: { root: f.repo } },
|
||||
arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] }, credentials: { gitea: { file: token, rotateBy: '2099-01-01' } } } } };
|
||||
arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] }, credentials: { gitea: { file: token, rotateBy: '2099-01-01' } } } } };
|
||||
const runtime = await startBroker({ dataRoot, businesses: { acme: business } });
|
||||
await runtime.close();
|
||||
const mirror = library.mirrorPath({ business, project: 'books', dataRoot });
|
||||
@@ -81,7 +81,7 @@ test('C2-04 runtime: protectedRoots skips library but retains real code-root cre
|
||||
test('C2-04 runtime: unsafe existing mirror refuses boot and never publishes a socket', async t => {
|
||||
const f = repo(t), dataRoot = join(f.root, 'data');
|
||||
mkdirSync(dataRoot, { mode: 0o700 });
|
||||
const business = { id: 'acme', human: 'jason', projects: { books: mappingEntry() }, arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } };
|
||||
const business = { id: 'acme', human: 'jason', projects: { books: mappingEntry() }, arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } };
|
||||
const mirror = join(dataRoot, 'projects/acme/books'); mkdirSync(mirror, { recursive: true });
|
||||
writeFileSync(join(mirror, 'keep'), 'made-up note');
|
||||
await assert.rejects(startBroker({ dataRoot, businesses: { acme: business } }), { code: 'library-git' });
|
||||
@@ -92,7 +92,7 @@ test('C2-04 runtime: unsafe existing mirror refuses boot and never publishes a s
|
||||
test('C2-04 runtime: real library adapters reach the socket and lock waits leave other bus reads responsive', { timeout: 15000 }, async t => {
|
||||
const f = repo(t), dataRoot = join(f.root, 'data');
|
||||
mkdirSync(dataRoot, { mode: 0o700 });
|
||||
const business = { id: 'acme', human: 'jason', projects: { books: mappingEntry() }, arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } };
|
||||
const business = { id: 'acme', human: 'jason', projects: { books: mappingEntry() }, arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } };
|
||||
const runtime = await startBroker({ dataRoot, businesses: { acme: business } });
|
||||
t.after(() => runtime.close());
|
||||
const cap = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
|
||||
@@ -26,7 +26,7 @@ const doc = (extra = {}) => ({
|
||||
retired: ['old'],
|
||||
...extra,
|
||||
});
|
||||
const OTHER = { id: 'other', human: 'jason', arbiters: { technical: 'only-there', delivery: 'only-there' }, roles: { 'only-there': none } };
|
||||
const OTHER = { id: 'other', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, 'only-there': none } };
|
||||
|
||||
function setup(t, { organization = null, testerOn = undefined, processing = undefined } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'lifecycle-verbs-'));
|
||||
@@ -349,7 +349,7 @@ test('row 194 V5 retry: a retired name deletes again only after workspace-rename
|
||||
const { transaction } = f.b.lifecycleDelete({ business: 'demo', request });
|
||||
f.done(request, { transaction, refusal: 'workspace-rename-failed' });
|
||||
// The bus reloads after the commit, as the runtime does.
|
||||
const next = doc({ retired: ['old', 'pm'], arbiters: { technical: 'coder', delivery: 'chief' } });
|
||||
const next = doc({ retired: ['old', 'pm'], arbiters: { technical: 'chief', delivery: 'chief' } });
|
||||
delete next.roles.pm;
|
||||
f.b.reloadBusiness('demo', next);
|
||||
const writes = w.calls.length;
|
||||
|
||||
@@ -13,8 +13,8 @@ import { Broker } from '../src/broker.mjs';
|
||||
|
||||
const shipped = name => JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
|
||||
const businesses = () => ({ demo: {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
coder: { definition: 'coder', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
},
|
||||
|
||||
@@ -20,8 +20,8 @@ async function setup(t, plugins, limit = 0) {
|
||||
writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'),
|
||||
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } }, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } },
|
||||
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } }, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } },
|
||||
organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
return { bus, human: bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) };
|
||||
|
||||
@@ -8,7 +8,7 @@ import {Broker} from '../src/broker.mjs';
|
||||
import {Store} from '../src/store.mjs';
|
||||
test('limits binds actor from human proof and rejects role, reader, a tester set and forged actor',t=>{
|
||||
const root=mkdtempSync(join(tmpdir(),'mosaic-limits-bus-')),store=new Store(root);t.after(()=>{store.close();rmSync(root,{recursive:true,force:true});});const calls=[];
|
||||
const b=new Broker({store,businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},vars:{'bus.tester':true},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:(...args)=>{calls.push(args);return {revision:'b'.repeat(64)};}});
|
||||
const b=new Broker({store,businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},vars:{'bus.tester':true},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:(...args)=>{calls.push(args);return {revision:'b'.repeat(64)};}});
|
||||
const human=b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true}),role=b.bindLaunch({business:'demo',role:'coder',run:'coder-run',harness:'pi',address:'coder-run'}),reader=b.bindReader({business:'demo'}),tester=b.bindTester({business:'demo'}),patch={revision:'a'.repeat(64),limits:{total:0}};
|
||||
for(const cap of [role,reader])for(const verb of ['limits','limits.set'])assert.throws(()=>b.request(cap,{verb,args:verb==='limits'?{}:patch}),/human-required/);
|
||||
// Row 175 (#1673): the tester reads limits; limits.set stays the human's.
|
||||
@@ -19,7 +19,7 @@ test('limits binds actor from human proof and rejects role, reader, a tester set
|
||||
test('runtime wires verified human settings to the actual shared file writer and actor receipt',async t=>{
|
||||
const {startBroker}=await import('../src/runtime.mjs');const {businessDoc,rolesCopy,systemFor,writeJson}=await import('../../organization/tests/helpers.mjs');const {readFileSync}=await import('node:fs');
|
||||
const root=mkdtempSync(join(tmpdir(),'mosaic-limits-runtime-')),rolesDir=rolesCopy(root),doc=businessDoc(root,'demo');writeJson(join(root,'businesses/demo.json'),doc);mkdirSync(join(root,'data'),{mode:0o700});
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)}});
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)}});
|
||||
t.after(async()=>{await bus.close();rmSync(root,{recursive:true,force:true});});const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
const view=bus.broker.request(human,{verb:'limits'});const done=bus.broker.request(human,{verb:'limits.set',args:{revision:view.revision,limits:{total:0}}});assert.equal(done.limits.total,0);assert.equal(JSON.parse(readFileSync(join(root,'businesses/demo.json'),'utf8')).launch.limits.total,0);const receipt=JSON.parse(readFileSync(join(root,'businesses/.history/demo/changes.jsonl'),'utf8').trim().split('\n').at(-1));assert.equal(receipt.actor,'jason');assert.equal(receipt.state,'committed');
|
||||
});
|
||||
@@ -28,7 +28,7 @@ test('uncertain organization receipts leave bus startup and reads alone but refu
|
||||
const root=mkdtempSync(join(tmpdir(),'mosaic-limits-recovery-')),rolesDir=rolesCopy(root),doc=businessDoc(root,'demo'),file=writeJson(join(root,'businesses/demo.json'),doc);mkdirSync(join(root,'data'),{mode:0o700});
|
||||
const writer=organizationWriter({dir:root,rolesDir}),original=readFileSync(file,'utf8'),revision=writer.read('demo').revision,next=structuredClone(doc);next.launch.limits.total=7;
|
||||
const interrupted=organizationWriter({dir:root,rolesDir,barrier:()=>{throw Error('fixture interruption');}});assert.throws(()=>interrupted.write('demo',next,{revision,actor:'jason'}));const external=structuredClone(doc);external.launch.limits.total=12;writeJson(file,external);const bytes=readFileSync(file,'utf8');
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)}});t.after(async()=>{await bus.close();rmSync(root,{recursive:true,force:true});});
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)}});t.after(async()=>{await bus.close();rmSync(root,{recursive:true,force:true});});
|
||||
const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});const view=bus.broker.request(human,{verb:'limits'});assert.equal(view.limits.total,12);assert.throws(()=>bus.broker.request(human,{verb:'limits.set',args:{revision:view.revision,limits:{total:1}}}),/organization-uncertain/);assert.equal(readFileSync(file,'utf8'),bytes);
|
||||
// The operator reconciles the fixture to the recorded old whole document.
|
||||
writeJson(file,original);const reconciled=bus.broker.request(human,{verb:'limits'});const result=bus.broker.request(human,{verb:'limits.set',args:{revision:reconciled.revision,limits:{total:1}}});assert.equal(result.limits.total,1);
|
||||
|
||||
@@ -15,7 +15,7 @@ function fixture(t, cross = false) {
|
||||
arbiters: { technical: 'cto', delivery: 'pm' },
|
||||
roles: {
|
||||
pm: role(cross ? [] : ['message.send'], cross ? ['message.send'] : []),
|
||||
cto: role(),
|
||||
cto: role(['decision.resolve.technical']),
|
||||
coder: role(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -46,7 +46,7 @@ test('launch admission refreshes selection and refuses a removed model and incom
|
||||
writeJson(join(root,'businesses/demo.json'),doc); mkdirSync(join(root,'data'),{mode:0o700});
|
||||
let present=true, reads=0;
|
||||
const service={invalidate(){},read:async(_,{fresh})=>{assert.equal(fresh,true);reads++;return {status:'ready',models:present?[{module:'cliproxy',upstream:'zai',model:'fixture-id',available:true,harnesses:['pi']}]:[]};}};
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue:service});t.after(()=>bus.close());
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue:service});t.after(()=>bus.close());
|
||||
const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
const revision=bus.broker.request(human,{verb:'limits'}).revision;
|
||||
const {checkedTransport}=await import('../../harness/src/bundle.mjs');
|
||||
@@ -78,7 +78,7 @@ test('a different enabled fixture provider supplies legacy mapping and admission
|
||||
const makeProviderLoader=()=>({load:async()=>({refused:[]}),snapshot:()=>[{name:'second-fixture',module}]});
|
||||
const offered={key:'fixture-model',module:'second-fixture',upstream:'fixture',provider:'fixture',model:'fixture-model',label:'Fixture',available:true,reason:null,harnesses:['pi']};
|
||||
const modelCatalogue={read:async()=>({status:'ready',models:[offered],notices:[]}),invalidate(){}};
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue,makeProviderLoader});t.after(()=>bus.close());
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue,makeProviderLoader});t.after(()=>bus.close());
|
||||
const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});const view=await bus.broker.request(human,{verb:'routes'});
|
||||
assert.equal(view.catalogue.legacy['fixture-legacy'].module,'second-fixture');
|
||||
const answer=await bus.modelAdmission({business:'demo',instance:'coder',modelKey:'fixture-legacy',harness:'pi',revision:view.revision,entry:doc.launch.models['fixture-legacy'],provider:'fixture',model:'fixture-model',account:null,route:'primary',transport});
|
||||
|
||||
@@ -27,7 +27,7 @@ export async function ownershipFixture(t,{launches=true,listed=true,keep=false,r
|
||||
t.after(async()=>{const failures=[];for(const g of gates)g.release();try{await waitFor(()=>active.size===0,'owned operations did not settle',5000);}catch{failures.push('owned-operation-close');}for(const close of [...closers].reverse()){try{await close();}catch{failures.push('owned-close');}}try{server?.closeAllConnections();if(server?.listening)await new Promise((resolve,reject)=>server.close(e=>e?reject(e):resolve()));}catch{failures.push('owned-server-close');}try{checkHistory({logs,proxyReceipts,trace});}catch{failures.push('value-free-receipts');}try{checkHistory({launch:launchLog()});}catch{failures.push('owned-launch-log-guard');}if(stderr)process.stderr.write=stderr;try{fs.rmSync(root,{recursive:true,force:true});}catch{failures.push('owned-root-close');}if(failures.length)throw new AggregateError(failures.map(code=>Error(code)),'owned managed fixture cleanup failed');});
|
||||
const f=fixture(root,'acme',doc=>{doc.plugins={...doc.plugins,harness:['pi']};for(const role of Object.values(doc.roles))role.vars={...role.vars,harness:'pi'};doc.roles.chief.vars.model='claude-opus-5-5';doc.roles.aide={definition:'aide',vars:{harness:'pi',model:'claude-opus-5-5',thinking:'high'}};if(listed)doc.launch.instances.push('aide');if(keep)doc.launch.keep=['aide'];if(retired)doc.retired=['alpha'];if(noHire)doc.launch.noHire=['aide'];return doc;});
|
||||
// The established scratch aide pattern only; repository roles/policy stay untouched.
|
||||
for(const name of ['aide','helper']){fs.writeFileSync(join(f.roles,name+'.md'),'# Scratch aide\n');writeJson(join(f.roles,name+'.json'),{roleVersion:2,name,title:'Aide',contract:name+'.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});}
|
||||
for(const name of ['aide','helper']){fs.writeFileSync(join(f.roles,name+'.md'),'# Scratch aide\n\n## Authority\n');writeJson(join(f.roles,name+'.json'),{roleVersion:2,name,title:'Aide',contract:name+'.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});}
|
||||
const chiefFile=join(f.roles,'chief-of-staff.json');writeJson(chiefFile,{...JSON.parse(fs.readFileSync(chiefFile,'utf8')),hireable:['aide','helper']});
|
||||
const env={PATH:process.env.PATH,HOME:process.env.HOME,TMPDIR:process.env.TMPDIR,MOSAIC_CONFIG:f.config,MOSAIC_ROLES_DIR:f.roles};
|
||||
const configDir=join(root,'config'),prefix=`msk-${id(configDir)}-`,sentinel=prefix+'sentinel-'+randomBytes(32).toString('base64url');history.add(sentinel);
|
||||
|
||||
@@ -176,7 +176,7 @@ test('proxy-stop-failed: old proxy key and retiring record survive a failed grac
|
||||
async function bootBusiness(f, binding = {}) {
|
||||
const rolesDir = rolesCopy(f.root), system = systemFor(f.root), doc = businessDoc(f.root, 'demo');
|
||||
// Aide is needed because shipped chief hireable:[] and security-reviewer has review.verdict outside HIRE_ALLOWED.
|
||||
fs.writeFileSync(join(rolesDir, 'aide.md'), '# Aide\n');
|
||||
fs.writeFileSync(join(rolesDir, 'aide.md'), '# Aide\n\n## Authority\n');
|
||||
writeJson(join(rolesDir, 'aide.json'), {
|
||||
roleVersion: 2, name: 'aide', title: 'Aide', contract: 'aide.md', tools: ['read'], network: 'api-only',
|
||||
authority: { withinRole: ['message.send'], crossRole: [] }, credentials: [],
|
||||
|
||||
@@ -15,7 +15,7 @@ async function hireFixture(t,{revoking=false}={}){
|
||||
if(revoking){const r=old.credentials.modelRecord('demo/orphan');old.credentials.writeModel('demo/orphan',{...r,state:'revoking',revokingAt:'2026-10-25T00:00:00.000Z'});}old.credentials.close();
|
||||
const rolesDir=rolesCopy(f.root),system=systemFor(f.root),doc=businessDoc(f.root,'demo');
|
||||
// Scratch aide: shipped chief hireable:[] and review.verdict is outside HIRE_ALLOWED.
|
||||
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});
|
||||
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n\n## Authority\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});
|
||||
const chiefFile=join(rolesDir,'chief-of-staff.json');writeJson(chiefFile,{...JSON.parse(fs.readFileSync(chiefFile,'utf8')),hireable:['aide']});doc.plugins={...doc.plugins,harness:['pi']};
|
||||
const file=writeJson(join(f.configDir,'businesses','demo.json'),doc);fs.writeFileSync(join(f.configDir,'secrets','proxy-management.key'),'fx651-manage',{mode:0o600});
|
||||
const business=loadBusiness('demo',{dir:f.configDir,rolesDir}),resolved={};for(const instance of Object.keys(business.roles))resolved[instance]=resolveInstance({system,business,instance});
|
||||
|
||||
@@ -22,7 +22,7 @@ export async function pendingFixture(t,{twoBusinesses=false}={}){
|
||||
const configDir=join(root,'config'),dataRoot=join(root,'data'),rolesDir=rolesCopy(root),system=systemFor(root);fs.mkdirSync(dataRoot,{mode:0o700});
|
||||
const doc=businessDoc(root,'demo');doc.plugins={...doc.plugins,harness:['pi']};
|
||||
// Existing row331 scratch aide pattern only; repository role authority unchanged.
|
||||
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});const chiefFile=join(rolesDir,'chief-of-staff.json');writeJson(chiefFile,{...JSON.parse(fs.readFileSync(chiefFile,'utf8')),hireable:['aide']});
|
||||
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n\n## Authority\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});const chiefFile=join(rolesDir,'chief-of-staff.json');writeJson(chiefFile,{...JSON.parse(fs.readFileSync(chiefFile,'utf8')),hireable:['aide']});
|
||||
const ids=twoBusinesses?['demo','other']:['demo'],files={},businesses={};for(const name of ids){const document={...structuredClone(doc),id:name};files[name]=writeJson(join(configDir,'businesses',name+'.json'),document);const business=loadBusiness(name,{dir:configDir,rolesDir}),resolved={};for(const instance of Object.keys(business.roles))resolved[instance]=resolveInstance({system,business,instance});businesses[name]=busOrganization(business,resolved);}
|
||||
fs.mkdirSync(join(configDir,'secrets'),{recursive:true,mode:0o700});fs.writeFileSync(join(configDir,'secrets','proxy-management.key'),'fx654-manage',{mode:0o600});fs.mkdirSync(join(dataRoot,'bus-host'),{recursive:true,mode:0o700});writeJson(sessionsFile(dataRoot),{sessionsVersion:SESSIONS_VERSION,sessions:[]});
|
||||
const sentinel=`msk-${hash(configDir).slice(0,12)}-sentinel-${randomBytes(32).toString('base64url')}`;state.keys.push(sentinel);history.add(sentinel);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { join } from 'node:path';
|
||||
import { Broker } from '../src/broker.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
const role={authority:{withinRole:['message.send'],crossRole:[]}};
|
||||
function fixture(t,proxyInit){const store=new Store(mkdtempSync(join(tmpdir(),'p1-bus-')));t.after(()=>store.close());const b=new Broker({store,proxyInit,businesses:{demo:{id:'demo',human:'owner',arbiters:{technical:'coder',delivery:'coder'},vars:{'bus.tester':true},roles:{coder:role}}}});return {store,b,human:b.bindHuman({business:'demo',human:'owner',via:'cli',outsideAgent:true}),tester:b.bindTester({business:'demo'}),reader:b.bindReader({business:'demo'}),agent:b.bindLaunch({business:'demo',role:'coder',run:'p1-run',harness:'pi'})};}
|
||||
function fixture(t,proxyInit){const store=new Store(mkdtempSync(join(tmpdir(),'p1-bus-')));t.after(()=>store.close());const b=new Broker({store,proxyInit,businesses:{demo:{id:'demo',human:'owner',arbiters:{technical:'chief',delivery:'chief'},vars:{'bus.tester':true},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:role}}}});return {store,b,human:b.bindHuman({business:'demo',human:'owner',via:'cli',outsideAgent:true}),tester:b.bindTester({business:'demo'}),reader:b.bindReader({business:'demo'}),agent:b.bindLaunch({business:'demo',role:'coder',run:'p1-run',harness:'pi'})};}
|
||||
test('proxy.init only accepts path-bound outside-agent human; refuses agent/tester/reader',async t=>{const calls=[];const f=fixture(t,async a=>{calls.push(a);return {port:a.port,binary:'/fixture'};});for(const cap of [f.tester,f.agent])assert.throws(()=>f.b.request(cap,{verb:'proxy.init',args:{key:'p1-management'}}),e=>e.code==='human-required');assert.throws(()=>f.b.request(f.reader,{verb:'proxy.init',args:{key:'p1-management'}}),e=>e.code==='read-only');assert.equal(calls.length,0);assert.deepEqual(await f.b.request(f.human,{verb:'proxy.init',args:{key:'p1-management',port:18432}}),{port:18432,binary:'/fixture'});assert.equal(JSON.stringify(f.store.all('SELECT body FROM events')).includes('p1-management'),false);});
|
||||
for(const [code,args]of [['proxy-key-invalid',{key:'a\nb'}],['proxy-config-invalid',{key:'p1-management',port:8317}],['invalid-request',{key:'p1-management',binary:'/forged'}]])test(`proxy.init ${code}`,t=>{const f=fixture(t,()=>{throw Error('must not call');});assert.throws(()=>f.b.request(f.human,{verb:'proxy.init',args}),e=>e.code===code);});
|
||||
test('proxy.init not-configured',t=>{const f=fixture(t,null);assert.throws(()=>f.b.request(f.human,{verb:'proxy.init',args:{key:'p1-management'}}),e=>e.code==='not-configured');});
|
||||
|
||||
@@ -51,7 +51,7 @@ export async function revocationFixture(t,{runtime=false,onRoot=()=>{},missingBo
|
||||
let boot,writer,chief,human,busBoots=0;
|
||||
if(runtime){
|
||||
const rolesDir=rolesCopy(root),system=systemFor(root),doc=businessDoc(root,'demo');doc.plugins={...doc.plugins,harness:['pi']};doc.roles.aide={definition:'aide',vars:{harness:'pi',model:'claude-opus-5-5',thinking:'high'}};doc.launch.instances.push('aide');
|
||||
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});writeJson(join(configDir,'businesses','demo.json'),doc);
|
||||
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n\n## Authority\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});writeJson(join(configDir,'businesses','demo.json'),doc);
|
||||
const business=loadBusiness('demo',{dir:configDir,rolesDir}),resolved={};for(const name of Object.keys(business.roles))resolved[name]=resolveInstance({system,business,instance:name});
|
||||
boot={dataRoot,businesses:{demo:busOrganization(business,resolved)},organization:{dir:configDir,rolesDir,system},proxy:{port,binary:'/fixture-unused'}};writer=organizationWriter({dir:configDir,rolesDir});
|
||||
stderr=process.stderr.write;process.stderr.write=function(chunk,...args){if(String(chunk).startsWith('model-proxy: ')){receipts.push(String(chunk));args.find(a=>typeof a==='function')?.();return true;}return stderr.call(this,chunk,...args);};
|
||||
|
||||
@@ -24,7 +24,7 @@ const demo = () => ({
|
||||
demo: {
|
||||
id: 'demo',
|
||||
human: 'jason',
|
||||
arbiters: { technical: 'lead', delivery: 'chief' },
|
||||
arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles,
|
||||
definitions: { reviewer: {} },
|
||||
vars: { 'bus.tester': true },
|
||||
|
||||
@@ -9,7 +9,7 @@ import { createLimits, modelUsageKey } from '../src/limits.mjs';
|
||||
const key='012345abcdef';
|
||||
test('P5 broker preserves original plugin subject and derives internal run context from authenticated native caller',async t=>{
|
||||
const root=mkdtempSync(join(tmpdir(),'p5-context-')),store=new Store(root),asked=[];t.after(()=>{store.close();rmSync(root,{recursive:true,force:true});});
|
||||
const b=new Broker({store,businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},
|
||||
const b=new Broker({store,businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},
|
||||
limits:{mustStop:async(...args)=>{asked.push(args);return {stop:false};}},usage:{collect:async()=>({recorded:true}),view:()=>({})}});
|
||||
const human=b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
const cap=b.bindLaunch({business:'demo',role:'coder',run:'native-run',harness:'pi',provider:'fixture',model:'fake'});b.request(cap,{verb:'role.claim'});
|
||||
|
||||
@@ -30,7 +30,7 @@ test('P5 real runtime binds stack usage to issued key, daily stops at stack coun
|
||||
});
|
||||
await new Promise(r=>server.listen(0,'127.0.0.1',r));let bus;
|
||||
t.after(async()=>{await bus?.close();await new Promise(r=>server.close(r));rmSync(root,{recursive:true,force:true});});
|
||||
bus=await startBroker({dataRoot:data,businesses:{demo:{id:'demo',human:'jason',plugins:doc.plugins,arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{definition:'coder',authority:{withinRole:[],crossRole:[]}}}}},
|
||||
bus=await startBroker({dataRoot:data,businesses:{demo:{id:'demo',human:'jason',plugins:doc.plugins,arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{definition:'coder',authority:{withinRole:[],crossRole:[]}}}}},
|
||||
organization:{dir:config,rolesDir,system:systemFor(config)},proxy:{port:server.address().port,binary:'/fixture-unused'}});
|
||||
assert.equal(calls.some(p=>p.includes('usage/queue')),false);
|
||||
const model=await bus.modelPreflight({business:'demo',role:'coder'}),keyId=hash(model.key);
|
||||
@@ -77,7 +77,7 @@ test('R4 real runtime: a quota spent on A and moved to B with no reset still ref
|
||||
});
|
||||
await new Promise(r=>server.listen(0,'127.0.0.1',r));let bus;
|
||||
t.after(async()=>{await bus?.close();await new Promise(r=>server.close(r));rmSync(root,{recursive:true,force:true});});
|
||||
bus=await startBroker({dataRoot:data,businesses:{demo:{id:'demo',human:'jason',plugins:doc.plugins,arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{definition:'coder',authority:{withinRole:[],crossRole:[]}}}}},
|
||||
bus=await startBroker({dataRoot:data,businesses:{demo:{id:'demo',human:'jason',plugins:doc.plugins,arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{definition:'coder',authority:{withinRole:[],crossRole:[]}}}}},
|
||||
organization:{dir:config,rolesDir,system:systemFor(config)},proxy:{port:server.address().port,binary:'/fixture-unused'}});
|
||||
const stored=()=>JSON.parse(readFileSync(join(config,'businesses/demo.json'),'utf8')).launch.limits.tokens.keys;
|
||||
const may=async()=>(await bus.mayLaunch({business:'demo',instance:'coder',provider:'fixture',model:'fake'})).allow;
|
||||
|
||||
@@ -64,7 +64,7 @@ export async function telemetryFixture(t, { foreign = false, hold = false, waitM
|
||||
});
|
||||
await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));
|
||||
let bus=null,startPromise=null,closePromise=null;
|
||||
const start=()=>startPromise??=(startBroker({dataRoot:data,...(orgMode ? {instanceRoot:root} : {}),businesses:{demo:{id:'demo',human:'jason',plugins:doc.plugins,arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{definition:'coder',authority:{withinRole:[],crossRole:[]}}}}},
|
||||
const start=()=>startPromise??=(startBroker({dataRoot:data,...(orgMode ? {instanceRoot:root} : {}),businesses:{demo:{id:'demo',human:'jason',plugins:doc.plugins,arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{definition:'coder',authority:{withinRole:[],crossRole:[]}}}}},
|
||||
organization:{dir:config,rolesDir,system:systemFor(config)},proxy:{port:server.address().port,binary:'/fixture-unused'}}).then(value=>(bus=value)));
|
||||
const release=(fail=false)=>{state.hold=false;for(const res of state.held.splice(0)){if(res.destroyed)continue;if(fail){res.writeHead(503);res.end('{}');}else answerQueue(res);}};
|
||||
const close=()=>bus?(closePromise??=bus.close()):Promise.resolve();
|
||||
|
||||
@@ -37,7 +37,7 @@ test('runtime: the switch changes the organization file, the receipt carries the
|
||||
const rolesDir = rolesCopy(root);
|
||||
writeJson(join(root, 'businesses/demo.json'), businessDoc(root, 'demo'));
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const file = join(root, 'businesses/demo.json');
|
||||
@@ -72,7 +72,7 @@ test('vikunja:19/309 through the broker, a patch switches the kinds it names and
|
||||
const rolesDir = rolesCopy(root);
|
||||
writeJson(join(root, 'businesses/demo.json'), { ...businessDoc(root, 'demo'), plugins: { 'usage-source': ['meter'] } });
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const log = () => readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
|
||||
@@ -97,7 +97,7 @@ test('plugins read advertises loadable harnesses even when switched off', async
|
||||
const root=mkdtempSync(join(tmpdir(),'plugins-available-'));t.after(()=>rmSync(root,{recursive:true,force:true}));
|
||||
const rolesDir=rolesCopy(root);const doc=businessDoc(root,'demo');doc.plugins={harness:['claude-code']};
|
||||
writeJson(join(root,'businesses/demo.json'),doc);mkdirSync(join(root,'data'),{mode:0o700});
|
||||
const host=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)}});t.after(()=>host.close());
|
||||
const host=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)}});t.after(()=>host.close());
|
||||
const human=host.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
const view=host.broker.request(human,{verb:'plugins'});
|
||||
assert.deepEqual(view.plugins.harness,['claude-code']);assert.ok(view.available.harness.includes('pi'));assert.ok(view.available.harness.includes('claude-code'));
|
||||
|
||||
@@ -10,8 +10,8 @@ import { Client } from '../src/client.mjs';
|
||||
import { registerRuntime, updateRuntime, processIdentity } from '../../processing-control/src/runtimes.mjs';
|
||||
import { readControl } from '../../processing-control/src/state.mjs';
|
||||
|
||||
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { authority: { withinRole: ['message.send'], crossRole: [] } } } } };
|
||||
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: ['message.send'], crossRole: [] } } } } };
|
||||
async function broker(root) {
|
||||
const r = await startBroker({ dataRoot: root, businesses, readers: ['demo'] });
|
||||
const cap = r.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
|
||||
@@ -13,7 +13,7 @@ const state = await import('../../processing-control/src/state.mjs').catch(e =>
|
||||
throw e;
|
||||
});
|
||||
const businesses = { demo: { id: 'demo', human: 'jason', vars: { 'bus.tester': true },
|
||||
arbiters: { technical: 'coder', delivery: 'coder' }, roles: {
|
||||
arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
coder: { definition: 'coder', authority: { withinRole: ['message.send', 'record.write'], crossRole: [] } },
|
||||
} } };
|
||||
function fixture(t, extra = {}) {
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Broker, TESTER_VERBS, USER_VERBS, VIEWER_VERBS } from '../src/broker.mj
|
||||
const shipped = (name) => JSON.parse(readFileSync(new URL(`../../../roles/${name}.json`, import.meta.url), 'utf8'));
|
||||
const AT = '2026-11-05T00:00:00.000Z';
|
||||
const business = {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'continuity', delivery: 'continuity' },
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {
|
||||
continuity: { definition: 'continuity', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
|
||||
@@ -10,7 +10,7 @@ function fixture(t, project, secretCheck = () => {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'project-await-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: {
|
||||
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
coder: { definition: 'coder', authority: { withinRole: ['record.write'], crossRole: [] } },
|
||||
} };
|
||||
const broker = new Broker({ store, businesses: { demo: business }, project, secretCheck });
|
||||
|
||||
@@ -5,6 +5,7 @@ import { join } from 'node:path';
|
||||
import { fixture, git } from './spec-fixtures.mjs';
|
||||
import { serve } from '../src/server.mjs';
|
||||
import { Client } from '../src/client.mjs';
|
||||
import { refusalGuidance } from '../src/refusals.mjs';
|
||||
import { PROJECT_FILE_VERBS } from '../src/broker.mjs';
|
||||
// Row 148 (#1642): the human-only verbs behind the Console's spec and docs editor.
|
||||
const refusals = f => f.store.all("SELECT body FROM events WHERE kind='action.refused'").map(r => JSON.parse(r.body).code);
|
||||
@@ -109,11 +110,11 @@ test('a request that names the save verb first and another verb last is refused
|
||||
const f = await socketFixture(t);
|
||||
const pad = 'x'.repeat(100000);
|
||||
const sneaky = `{"human":{"fixture":true},"verb":"project.file.save","verb":"agents","args":{"pad":"${pad}"}}\n`;
|
||||
assert.deepEqual(await raw(f.path, sneaky), { ok: false, error: 'request-too-large' });
|
||||
assert.deepEqual(await raw(f.path, sneaky), { ok: false, error: 'request-too-large', ...refusalGuidance('request-too-large') });
|
||||
const unmarked = await raw(f.path, JSON.stringify({ human: { fixture: true }, verb: 'agents', args: { pad } }) + '\n');
|
||||
assert.deepEqual(unmarked, { ok: false, error: 'request-too-large' });
|
||||
assert.deepEqual(unmarked, { ok: false, error: 'request-too-large', ...refusalGuidance('request-too-large') });
|
||||
const over = await raw(f.path, JSON.stringify({ human: { fixture: true }, verb: 'project.file.save', args: { pad: 'x'.repeat(1024 * 1024 + 80000) } }) + '\n');
|
||||
assert.deepEqual(over, { ok: false, error: 'request-too-large' });
|
||||
assert.deepEqual(over, { ok: false, error: 'request-too-large', ...refusalGuidance('request-too-large') });
|
||||
});
|
||||
|
||||
test('two saves racing for one repository make one commit', async t => {
|
||||
|
||||
@@ -13,6 +13,8 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
|
||||
const source = readFileSync(new URL('../src/broker.mjs', import.meta.url), 'utf8');
|
||||
assert.deepEqual([...source.matchAll(/^import .*;$/gm)].map(m => m[0]), [
|
||||
// Rows330/331: pure verb/refusal vocabulary, never the host management implementation.
|
||||
"import { ownRails } from '../../organization/src/rails.mjs';",
|
||||
"import { refusalGuidance } from './refusals.mjs';",
|
||||
"import { PROXY_PAGE_VERBS, PROXY_PAGE_CODES } from './proxy-page-vocabulary.mjs';",
|
||||
// Row 210 (#1707): createHash gives host.run events the argv, stdin and output sha256 values.
|
||||
"import { randomUUID, randomBytes, createHash } from 'node:crypto';",
|
||||
@@ -64,7 +66,7 @@ test('real startBroker injects the project adapter for socket reads and bound-au
|
||||
t.after(async () => { await runtime?.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
// Row 65: the bound run's harness is switched on.
|
||||
const business = { id: 'demo', human: 'jason', plugins: { harness: ['pi'] }, projects: { stack: { root: join(root, 'code') } },
|
||||
arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: {
|
||||
arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: {
|
||||
definition: 'coder', authority: { withinRole: ['record.write'], crossRole: [] },
|
||||
} } };
|
||||
const initialized = await initProject({ business, project: 'stack', dataRoot: root });
|
||||
|
||||
@@ -15,7 +15,7 @@ test('role must not materialize a new catalogue key by submitting legacy entry s
|
||||
const offered={key:'zai-fixture-new',module:'cliproxy',upstream:'zai',provider:'zai',model:'fixture-new',label:'Fixture',available:true,reason:null,harnesses:['pi']};
|
||||
let status='ready';
|
||||
const modelCatalogue={invalidate(){},read:async()=>({status,models:[offered],notices:[]})};
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}},chief:{definition:'chief-of-staff',authority}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue});t.after(()=>bus.close());
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{coder:{authority:{withinRole:[],crossRole:[]}},chief:{definition:'chief-of-staff',authority}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue});t.after(()=>bus.close());
|
||||
const db=new DatabaseSync(join(root,'data/bus/bus.sqlite'),{readOnly:true});t.after(()=>db.close());
|
||||
const allowed=()=>db.prepare("SELECT count(*) AS n FROM events WHERE kind='action.allowed' AND json_extract(body,'$.action')='organization.change'").get().n;
|
||||
const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
|
||||
@@ -20,7 +20,7 @@ async function fixture(t, options={}) {
|
||||
if(options.fallback)Object.assign(doc.roles.coder.vars,{'fallback.harness':'pi','fallback.model':'existing-alias','fallback.thinking':'low'});
|
||||
writeJson(file,doc);fs.mkdirSync(join(root,'data'),{mode:0o700});
|
||||
const modelCatalogue={invalidate(){},read:async()=>({status:'ready',models:[offer],notices:[]})};
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{...Object.fromEntries(Object.keys(doc.roles).map(key=>[key,{authority:{withinRole:[],crossRole:[]}}])),chief:{definition:'chief-of-staff',authority}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue});t.after(()=>bus.close());
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{...Object.fromEntries(Object.keys(doc.roles).map(key=>[key,{authority:{withinRole:[],crossRole:[]}}])),chief:{definition:'chief-of-staff',authority}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue});t.after(()=>bus.close());
|
||||
const db=new DatabaseSync(join(root,'data/bus/bus.sqlite'),{readOnly:true});t.after(()=>db.close());
|
||||
const count=()=>db.prepare("SELECT count(*) AS n FROM events WHERE kind='action.allowed' AND json_extract(body,'$.action')='organization.change'").get().n;
|
||||
const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
|
||||
@@ -18,8 +18,8 @@ function setup(t, proxyPage = async () => ({ ok: true })) {
|
||||
access: () => ({ accessVersion: 1, revision: 1, users: [], grants: [
|
||||
{ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['coder'], level: 'collaborator' },
|
||||
] }),
|
||||
businesses: { demo: { id: 'demo', human: 'owner', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
vars: { 'bus.tester': true }, roles: { coder: { definition: 'coder', authority: { withinRole: ['message.send'], crossRole: [] } } } } },
|
||||
businesses: { demo: { id: 'demo', human: 'owner', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
vars: { 'bus.tester': true }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: ['message.send'], crossRole: [] } } } } },
|
||||
});
|
||||
const caps = {
|
||||
human: broker.bindHuman({ business: 'demo', human: 'owner', via: 'cli', outsideAgent: true }),
|
||||
|
||||
@@ -24,9 +24,9 @@ function businesses(upstream = 'https://git.example/mosaic/stack') {
|
||||
demo: {
|
||||
id: 'demo',
|
||||
human: 'jason',
|
||||
arbiters: { technical: 'reviewer', delivery: 'reviewer' },
|
||||
arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
projects: { stack: { root: '/nonexistent', remote: upstream }, books: { root: '/nonexistent-books' } },
|
||||
roles: {
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
coder: { authority: pusher, identity: identity('acme-coder'), publish: ['stack'] },
|
||||
second: { authority: pusher, identity: identity('acme-second'), publish: ['stack'] },
|
||||
plain: { authority: pusher },
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { connect } from 'node:net';
|
||||
import { PROXY_PAGE_CODES } from '../src/proxy-page-vocabulary.mjs';
|
||||
import { Broker, BusError } from '../src/broker.mjs';
|
||||
import { REFUSALS, refusalGuidance } from '../src/refusals.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { busOrganization } from '../src/organization-context.mjs';
|
||||
import { serve } from '../src/server.mjs';
|
||||
import { Client } from '../src/client.mjs';
|
||||
import { resolveInstance, validateBusinessDocument } from '../../organization/src/index.mjs';
|
||||
import { businessDoc, REPO_ROLES, systemFor } from '../../organization/tests/helpers.mjs';
|
||||
import { railsText } from '../../organization/src/rails.mjs';
|
||||
import { GUARD_CODES } from '../../library/src/reply-protocol.mjs';
|
||||
const SECRET = 'sk-test-NOT-A-SECRET-1234';
|
||||
function fixture(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'rails-'));
|
||||
const business = validateBusinessDocument(businessDoc(root), join(root, 'acme.json'), { rolesDir: REPO_ROLES });
|
||||
const resolve = b => Object.fromEntries(Object.keys(b.roles).map(instance => [instance, resolveInstance({ system: systemFor(root), business: b, instance })]));
|
||||
writeFileSync(business.roles.coder.credentials.gitea.file, SECRET);
|
||||
const resolved = resolve(business), store = new Store(root);
|
||||
const broker = new Broker({ store, businesses: { acme: busOrganization(business, resolved) } });
|
||||
const cap = broker.bindLaunch({ business: 'acme', role: 'coder', run: 'coder-run', harness: 'pi' });
|
||||
broker.request(cap, { verb: 'role.claim' });
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
return { root, business, resolved, resolve, broker, store, cap };
|
||||
}
|
||||
|
||||
test('my_rails equals the resolved prompt content, reads live narrowing, and writes/raises nothing', t => {
|
||||
const f = fixture(t), call = args => f.broker.request(f.cap, { verb: 'my_rails', args });
|
||||
const before = f.store.get('SELECT count(*) n FROM events').n;
|
||||
assert.deepEqual(call({}), f.resolved.coder.rails);
|
||||
const result = call({}); result.actions.length = 0;
|
||||
assert.notEqual(call({}).actions.length, 0, 'returned rails do not mutate policy');
|
||||
const business = structuredClone(f.business);
|
||||
business.roles.coder.vars['limits.authority'] = ['message.send'];
|
||||
const narrowed = f.resolve(business);
|
||||
f.broker.reloadBusiness('acme', busOrganization(business, narrowed));
|
||||
assert.deepEqual(call({}), narrowed.coder.rails);
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM events').n, before);
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, 0);
|
||||
});
|
||||
|
||||
test('my_rails cannot select another identity or expose credential refs and file/call values', t => {
|
||||
const f = fixture(t);
|
||||
for (const args of [{ agent: 'cto' }, { role: 'cto' }, { instance: 'cto' }, { business: 'other' }, { token: SECRET }]) {
|
||||
assert.throws(() => f.broker.request(f.cap, { verb: 'my_rails', args }), { code: 'invalid-request' });
|
||||
}
|
||||
const rails = f.broker.request(f.cap, { verb: 'my_rails' });
|
||||
const text = JSON.stringify(rails) + railsText(rails);
|
||||
for (const value of [SECRET, 'placeholder-not-a-token', f.resolved.coder.credentials.gitea.file, f.resolved.cto.credentials.gitea.file]) assert.ok(!text.includes(value), value);
|
||||
const human = f.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
assert.throws(() => f.broker.request(human, { verb: 'my_rails' }), { code: 'agent-required' });
|
||||
});
|
||||
|
||||
function raw(path, request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = connect(path); let out = '';
|
||||
socket.on('error', reject);
|
||||
socket.on('connect', () => socket.end(JSON.stringify(request) + '\n'));
|
||||
socket.on('data', b => out += b);
|
||||
socket.on('end', () => resolve(JSON.parse(out)));
|
||||
});
|
||||
}
|
||||
test('cross-role refusal envelope names its rail and next step without changing code or outcome', async t => {
|
||||
const f = fixture(t), path = join(f.root, 'bus.sock');
|
||||
const server = await serve({ broker: f.broker, path }); t.after(() => server.close());
|
||||
const request = { cap: f.cap, verb: 'decision.resolve', args: { id: 'missing', choice: SECRET } };
|
||||
const refused = await raw(path, request);
|
||||
assert.equal(refused.ok, false); assert.equal(refused.error, 'decision-not-found');
|
||||
assert.deepEqual({ rail: refused.rail, nextStep: refused.nextStep }, REFUSALS['decision-not-found']);
|
||||
assert.ok(!JSON.stringify(refused).includes(SECRET));
|
||||
await assert.rejects(new Client({ path, cap: f.cap }).call('decision.resolve', request.args), e => {
|
||||
assert.equal(e.code, 'decision-not-found'); assert.equal(e.nextStep, refused.nextStep); return true;
|
||||
});
|
||||
const decision = f.broker.request(f.cap, { verb: 'decision.raise', args: { action: 'task.scope.change', domain: 'delivery',
|
||||
question: 'Scope?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'no', blocking: false } });
|
||||
const cross = await raw(path, { cap: f.cap, verb: 'decision.resolve', args: { id: decision.id, choice: 'yes' } });
|
||||
assert.equal(cross.error, 'not-resolver'); assert.match(cross.rail, /resolve right/); assert.match(cross.nextStep, /routed resolver/);
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decision_events WHERE decision=?', decision.id).n, 0);
|
||||
// Exercise an actual cross-role action without its required decision, not just resolve.
|
||||
const live = busOrganization(f.business, f.resolved);
|
||||
live.roles.coder.authority.crossRole.push('role.end');
|
||||
f.broker.reloadBusiness('acme', live);
|
||||
const decisions = f.store.get('SELECT count(*) n FROM decisions').n;
|
||||
const required = await raw(path, { cap: f.cap, verb: 'role.revoke', args: { role: 'reviewer', reason: SECRET } });
|
||||
assert.equal(required.ok, false); assert.equal(required.error, 'decision-required');
|
||||
assert.deepEqual({ rail: required.rail, nextStep: required.nextStep }, REFUSALS['decision-required']);
|
||||
assert.ok(!JSON.stringify(required).includes(SECRET));
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, decisions);
|
||||
});
|
||||
|
||||
test('every literal fail branch in broker has static guidance; guidance never contains payload values', () => {
|
||||
const source = readFileSync(new URL('../src/broker.mjs', import.meta.url), 'utf8');
|
||||
// Balance each call (including ternaries), so a following statement cannot masquerade
|
||||
// as a fail code. Every literal branch, with either quote style, must be in the table.
|
||||
for (const match of source.matchAll(/\bfail\s*\(/g)) {
|
||||
let depth = 1, i = match.index + match[0].length;
|
||||
while (depth && i < source.length) {
|
||||
const char = source[i++];
|
||||
if (char === "'" || char === '"') {
|
||||
const start = i - 1;
|
||||
let value = '';
|
||||
while (i < source.length && source[i] !== char) {
|
||||
if (source[i] === "\\") { i += 2; continue; }
|
||||
value += source[i++];
|
||||
}
|
||||
i++;
|
||||
if (/^[a-z][a-z0-9-]*$/.test(value) && (value.includes('-') || !source.slice(match.index + match[0].length, start).trim())) assert.ok(Object.hasOwn(REFUSALS, value), value);
|
||||
} else if (char === '(') depth++;
|
||||
else if (char === ')') depth--;
|
||||
}
|
||||
assert.equal(depth, 0, 'balanced fail call');
|
||||
}
|
||||
// Finite delegated fail(code) sources must be covered as well as literal branches.
|
||||
for (const code of [...PROXY_PAGE_CODES, ...GUARD_CODES, 'project-busy', 'remote-host-mismatch', 'remote-not-fork',
|
||||
'processing-control-unsafe', 'processing-uninitialized', 'processing-generation-changed']) {
|
||||
assert.ok(Object.hasOwn(REFUSALS, code), code);
|
||||
}
|
||||
for (const [code, guidance] of Object.entries(REFUSALS)) {
|
||||
assert.ok(guidance.rail.length > 0 && guidance.nextStep.length > 0, code);
|
||||
assert.deepEqual({ rail: new BusError(code).rail, nextStep: new BusError(code).nextStep }, guidance);
|
||||
assert.ok(!JSON.stringify(guidance).includes(SECRET));
|
||||
}
|
||||
assert.deepEqual(refusalGuidance(SECRET), refusalGuidance('unknown-external-code'));
|
||||
});
|
||||
|
||||
test('finite forwarded project, library and request errors have static next steps', () => {
|
||||
const modules = [
|
||||
...['index', 'git', 'paths'].map(name => new URL(`../../project/src/${name}.mjs`, import.meta.url)),
|
||||
...readdirSync(new URL('../../library/src/', import.meta.url)).filter(f => f.endsWith('.mjs'))
|
||||
.map(name => new URL(`../../library/src/${name}`, import.meta.url)),
|
||||
...['request-store', 'request-lifecycle'].map(name => new URL(`../src/${name}.mjs`, import.meta.url)),
|
||||
];
|
||||
for (const file of modules) {
|
||||
const source = readFileSync(file, 'utf8');
|
||||
for (const match of source.matchAll(/\b(?:new\s+(?:ProjectError|LibraryError|RequestError|GuardError)|raise|refuse|fail)\s*\(\s*['"]([a-z][a-z0-9-]*)['"]/g)) {
|
||||
assert.ok(Object.hasOwn(REFUSALS, match[1]), `${file.pathname}: ${match[1]}`);
|
||||
}
|
||||
}
|
||||
assert.doesNotMatch(REFUSALS['outcome-unknown'].nextStep, /confirmation|human/);
|
||||
assert.doesNotMatch(REFUSALS['revision-conflict'].nextStep, /confirmation|human/);
|
||||
assert.match(REFUSALS['task-required'].nextStep, /task_ref.*blocking false/);
|
||||
});
|
||||
@@ -211,13 +211,13 @@ test('T14 reloadBusiness refuses an unknown business, a changed human and a docu
|
||||
const root = mkdtempSync(join(tmpdir(), 'reload-unit-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const role = { authority: { withinRole: [], crossRole: [] } };
|
||||
const demo = { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: role } };
|
||||
const demo = { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: role } };
|
||||
const b = new Broker({ store: new Store(root), businesses: { demo } });
|
||||
assert.equal(code(() => b.reloadBusiness('other', demo)), 'unknown-business');
|
||||
assert.equal(code(() => b.reloadBusiness('demo', { ...demo, human: 'mos' })), 'invalid-business');
|
||||
assert.equal(code(() => b.reloadBusiness('demo', { ...demo, roles: { coder: role, tester: role } })), 'invalid-business');
|
||||
assert.equal(code(() => b.reloadBusiness('demo', { ...demo, roles: { coder: { authority: { withinRole: ['spend'], crossRole: [] } } } })), 'invalid-authority');
|
||||
assert.equal(code(() => b.reloadBusiness('demo', null)), 'invalid-business');
|
||||
b.reloadBusiness('demo', { ...demo, roles: { coder: role, 'sec-1': role } });
|
||||
b.reloadBusiness('demo', { ...demo, roles: { ...demo.roles, 'sec-1': role } });
|
||||
assert.ok(b.bindLaunch({ business: 'demo', role: 'sec-1', run: 'run-1', harness: 'pi' }));
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
// RR (#1873): resolution checks current role rights, and callers cannot choose a merge/policy arbiter.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { loadRole } from '../../organization/src/index.mjs';
|
||||
import { REPO_ROLES } from '../../organization/tests/helpers.mjs';
|
||||
import { fixture } from './spec-fixtures.mjs';
|
||||
import { grantDecision } from './host-fixtures.mjs';
|
||||
|
||||
async function setup(t, { shared = false } = {}) {
|
||||
const roles = Object.fromEntries(['cto', 'pm', 'chief-of-staff'].map(definition => {
|
||||
const role = loadRole(REPO_ROLES, definition);
|
||||
return [definition === 'chief-of-staff' ? 'chief' : definition, { definition, authority: structuredClone(role.authority) }];
|
||||
}));
|
||||
roles.coder = { definition: 'coder', authority: { withinRole: ['message.send', 'record.write'], crossRole: ['task.scope.change', 'git.merge.protected', 'policy.change'] } };
|
||||
const f = await fixture(t, { roles, arbiters: { technical: 'cto', delivery: shared ? 'cto' : 'pm' } });
|
||||
return { ...f, roles, cto: f.agent('cto'), pm: f.agent('pm'), chief: f.chief };
|
||||
}
|
||||
|
||||
for (const [domain, resolver] of [['technical', 'cto'], ['delivery', 'pm']]) {
|
||||
test(`RR: ${domain} arbiter that loses its right refuses not-resolver`, async t => {
|
||||
const f = await setup(t);
|
||||
const d = f.raise({ action: 'task.scope.change', domain });
|
||||
f.business.roles[resolver].authority.withinRole = ['message.send'];
|
||||
f.broker.reloadBusiness('demo', f.business);
|
||||
assert.throws(() => f.call(f[resolver], 'decision.resolve', { id: d.id, choice: 'yes' }), error => {
|
||||
assert.equal(error.code, 'not-resolver');
|
||||
assert.match(error.reason, new RegExp(`${resolver}.*${resolver}.*decision\\.resolve\\.${domain}`));
|
||||
return true;
|
||||
});
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decision_events WHERE decision=?', d.id).n, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test('RR: a shared arbiter cannot resolve a delivery decision using only its technical right', async t => {
|
||||
const f = await setup(t, { shared: true });
|
||||
const d = f.raise({ action: 'task.scope.change', domain: 'delivery' });
|
||||
assert.equal(d.route_to, 'cto');
|
||||
assert.throws(() => f.call(f.cto, 'decision.resolve', { id: d.id, choice: 'yes' }), { code: 'not-resolver' });
|
||||
});
|
||||
|
||||
test('RR: merge raised with delivery still routes to CTO and resolves (acceptance 314 step 5)', async t => {
|
||||
const f = await setup(t);
|
||||
const d = await f.raise({ action: 'git.merge.protected', domain: 'delivery', project: 'stack', spec_path: 'spec/north-star.md', spec_commit: f.commit });
|
||||
assert.equal(d.route_to, 'cto');
|
||||
assert.throws(() => f.call(f.pm, 'decision.resolve', { id: d.id, choice: 'yes' }), { code: 'not-resolver' });
|
||||
f.call(f.cto, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
assert.equal(f.store.get('SELECT op FROM decision_events WHERE decision=?', d.id).op, 'resolved');
|
||||
});
|
||||
|
||||
for (const resolver of ['chief', 'pm']) {
|
||||
test(`RR: ${resolver} named delivery arbiter resolves policy.change even when raised technical`, async t => {
|
||||
const f = await setup(t); f.business.arbiters.delivery = resolver;
|
||||
f.broker.reloadBusiness('demo', f.business);
|
||||
const d = f.raise({ action: 'policy.change', domain: 'technical' });
|
||||
assert.equal(d.route_to, resolver);
|
||||
f.call(f[resolver], 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
assert.equal(f.store.get('SELECT by FROM decision_events WHERE decision=?', d.id).by, `${resolver}-run`);
|
||||
});
|
||||
}
|
||||
|
||||
test('RR: the chief holds both rights when named for both domains', async t => {
|
||||
const f = await setup(t); f.business.arbiters = { technical: 'chief', delivery: 'chief' };
|
||||
f.broker.reloadBusiness('demo', f.business);
|
||||
for (const action of ['git.merge.protected', 'policy.change']) {
|
||||
const d = f.raise({ action }); assert.equal(d.route_to, 'chief');
|
||||
f.call(f.chief, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
}
|
||||
});
|
||||
|
||||
// Old raise evidence did not preserve domain. Current assignments cannot prove the old domain.
|
||||
test('RR: remapping arbiters cannot let a legacy decision resolve with only the other domain right', async t => {
|
||||
const f = await setup(t);
|
||||
const id = grantDecision(f.store, { action: 'task.scope.change', route: 'pm', domain: null, close: null });
|
||||
f.business.arbiters = { technical: 'pm', delivery: 'cto' };
|
||||
f.business.roles.pm.authority.withinRole = ['message.send', 'decision.resolve.technical'];
|
||||
f.broker.reloadBusiness('demo', f.business);
|
||||
assert.throws(() => f.call(f.pm, 'decision.resolve', { id, choice: 'yes' }), { code: 'not-resolver' });
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decision_events WHERE decision=?', id).n, 0);
|
||||
// Human recovery closes the old route by withdrawing it, before the raiser creates a new decision.
|
||||
f.call(f.human, 'decision.withdraw', { id });
|
||||
assert.equal(f.store.get('SELECT op FROM decision_events WHERE decision=?', id).op, 'withdrawn');
|
||||
});
|
||||
@@ -28,7 +28,7 @@ test('C2-10c check hydration preserves unchecked rows and one broadcast with two
|
||||
const messaging = JSON.parse(readFileSync(new URL('../../../roles/companion.json', import.meta.url))).messaging;
|
||||
const role = { definition: 'companion', authority: { withinRole: ['message.send'], crossRole: [] } };
|
||||
const broker = new Broker({ store, businesses: { demo: { id: 'demo', human: 'jason', vars: {},
|
||||
arbiters: { technical: 'bean', delivery: 'bean' }, roles: { bean: role, chet: role }, definitions: { companion: { messaging } } } } });
|
||||
arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, bean: role, chet: role }, definitions: { companion: { messaging } } } } });
|
||||
const human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const call = (verb, args) => broker.request(human, { verb, args });
|
||||
const project = call('project.create', { name: 'Room' }).id;
|
||||
|
||||
@@ -110,7 +110,7 @@ test('row 65 runtime: routes and routes.set reach the organization file with the
|
||||
const file = join(root, 'businesses/demo.json');
|
||||
writeJson(file, doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
t.after(() => bus.close());
|
||||
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const log = () => readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
|
||||
@@ -157,7 +157,7 @@ test('fixture provider catalogue appears in actual routes and materializes the s
|
||||
const file = join(root, 'businesses/demo.json'); writeJson(file, doc); mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const offered={ key:'zai-fixture-id',module:'cliproxy',upstream:'zai',provider:'zai',model:'fixture-id',label:'fixture-id',available:true,reason:null,harnesses:['pi'] };
|
||||
const catalogues={ read:async()=>({status:'ready',observedAt:'2026-11-06T00:00:00.000Z',models:[offered],notices:[]}),invalidate:()=>{} };
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}} } }, organization:{dir:root,rolesDir,system:systemFor(root)}, modelCatalogue:catalogues });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}} } }, organization:{dir:root,rolesDir,system:systemFor(root)}, modelCatalogue:catalogues });
|
||||
t.after(() => bus.close());
|
||||
const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
const before=await bus.broker.request(human,{verb:'routes'});
|
||||
@@ -179,7 +179,7 @@ for (const writer of ['limits.set','organization.set','instances.set']) test(`${
|
||||
let mode='absent';
|
||||
const offered={key:'fixture-persisted',module:'cliproxy',upstream:'zai',provider:'zai',model:'fixture-id',label:'fixture',available:true,reason:null,harnesses:['pi']};
|
||||
const catalogue={read:async()=>({status:'ready',observedAt:'2026-11-06T00:00:00.000Z',models:mode==='absent'?[]:[{...offered,harnesses:mode==='incompatible'?['claude-code']:['pi']}],notices:[]}),invalidate(){}};
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}},chief:{definition:'chief-of-staff',authority:chiefAuthority}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue:catalogue});t.after(()=>bus.close());
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{coder:{authority:{withinRole:[],crossRole:[]}},chief:{definition:'chief-of-staff',authority:chiefAuthority}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue:catalogue});t.after(()=>bus.close());
|
||||
const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
const cap=writer==='organization.set'?bus.broker.bindLaunch({business:'demo',role:'chief',run:'provider-chief',harness:'pi'}):human;
|
||||
if(writer==='organization.set')bus.broker.request(cap,{verb:'role.claim'});
|
||||
@@ -201,7 +201,7 @@ test('legacy routes follow enabled module mappings while unchanged unavailable s
|
||||
writeJson(join(root,'businesses/demo.json'),doc);mkdirSync(join(root,'data'),{mode:0o700});
|
||||
let state='ready';const offered={key:'zai-fixture-two',module:'cliproxy',upstream:'zai',provider:'zai',model:'fixture-two',label:'fixture',available:true,reason:null,harnesses:['pi']};
|
||||
const catalogue={read:async()=>({status:state,observedAt:'2026-11-06T00:00:00.000Z',models:[offered],notices:[]}),invalidate(){}};
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue:catalogue});t.after(()=>bus.close());
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)},modelCatalogue:catalogue});t.after(()=>bus.close());
|
||||
const human=bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});const request=(verb,args)=>bus.broker.request(human,{verb,args});
|
||||
let view=await request('routes');assert.deepEqual(view.catalogue.legacy['legacy-one'],{module:'cliproxy',upstream:'claude',model:'fixture-one'});
|
||||
const pick=(harness,model,fallback=null)=>({harness,model,thinking:'low',fallback});
|
||||
|
||||
@@ -12,8 +12,8 @@ const businesses = {
|
||||
arbiters: { technical: 'cto', delivery: 'pm' },
|
||||
roles: {
|
||||
coder: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } },
|
||||
cto: { authority: { withinRole: [], crossRole: [] } },
|
||||
pm: { authority: { withinRole: [], crossRole: [] } },
|
||||
cto: { authority: { withinRole: ['decision.resolve.technical'], crossRole: [] } },
|
||||
pm: { authority: { withinRole: ['decision.resolve.delivery'], crossRole: [] } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Broker } from '../src/broker.mjs';
|
||||
import { initProject, readSpec, writeRecord, listProjectFiles, readProjectFile, writeHumanFile, ProjectError } from '../../project/src/index.mjs';
|
||||
export const git = (repo, ...args) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'maintenance.auto=false', '-c', 'gc.auto=0', '-C', repo, ...args], { encoding: 'utf8' }).trim();
|
||||
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
|
||||
export async function fixture(t, { adapter = true, roles = {}, arbiters = { technical: 'reviewer', delivery: 'reviewer' }, access = null } = {}) {
|
||||
export async function fixture(t, { adapter = true, roles = {}, arbiters = { technical: 'chief', delivery: 'chief' }, access = null } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-spec-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
@@ -16,6 +16,7 @@ export async function fixture(t, { adapter = true, roles = {}, arbiters = { tech
|
||||
projects: { stack: { root: join(root, 'code'), repo: join(root, 'records') } }, roles: {
|
||||
coder: { definition: 'coder', authority: authority(['message.send', 'record.write']) },
|
||||
reviewer: { definition: 'reviewer', authority: authority(['message.send']) },
|
||||
chief: { definition: 'chief-of-staff', authority: authority(['message.send', 'decision.resolve.technical', 'decision.resolve.delivery']) },
|
||||
...roles,
|
||||
} };
|
||||
const { repo, commit } = await initProject({ business, project: 'stack', dataRoot: root });
|
||||
@@ -31,7 +32,7 @@ export async function fixture(t, { adapter = true, roles = {}, arbiters = { tech
|
||||
const cap = broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi', address: role });
|
||||
broker.request(cap, { verb: 'role.claim' }); return cap;
|
||||
};
|
||||
const coder = agent('coder'), reviewer = agent('reviewer');
|
||||
const coder = agent('coder'), reviewer = agent('reviewer'), chief = agent('chief');
|
||||
const human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const call = (cap, verb, args = {}) => broker.request(cap, { verb, args });
|
||||
const raise = args => call(coder, 'decision.raise', { action: 'deploy', question: 'Ship?', options,
|
||||
@@ -42,5 +43,5 @@ export async function fixture(t, { adapter = true, roles = {}, arbiters = { tech
|
||||
git(repo, '-c', `user.name=${author}`, '-c', '[email protected]', 'commit', '-m', 'Spec');
|
||||
return git(repo, 'rev-parse', 'HEAD');
|
||||
};
|
||||
return { root, store, business, broker, repo, commit, agent, coder, reviewer, human, call, raise, save };
|
||||
return { root, store, business, broker, repo, commit, agent, coder, reviewer, chief, human, call, raise, save };
|
||||
}
|
||||
@@ -19,7 +19,7 @@ async function setup(t, { running = ['chief', 'pm', 'cto'], thread = true } = {}
|
||||
cto: { definition: 'cto', ...within }, assistant: { definition: 'assistant', ...within },
|
||||
} });
|
||||
f.business.definitions = { assistant: { messaging: shipped('assistant').messaging } };
|
||||
f.cap = Object.fromEntries(running.map(role => [role, f.agent(role)]));
|
||||
f.cap = Object.fromEntries(running.map(role => [role, role === 'chief' ? f.chief : f.agent(role)]));
|
||||
if (thread) {
|
||||
const project = f.call(f.human, 'project.create', { name: 'stack work', repo: 'stack' }).id;
|
||||
f.threadId = f.call(f.human, 'thread.create', { project, name: 'alpha' }).id;
|
||||
|
||||
@@ -139,7 +139,7 @@ test('REQ-COS-1: the request goes to the business launcher, and only when that i
|
||||
// launcher `chief`, so a check on the name passes there; these use names that differ from the definitions.
|
||||
test('REQ-COS-1: the sender is judged by definition: an instance `lead` of the pm definition can ask, an instance `pm` of the coder definition can\'t', (t) => {
|
||||
const lead = setup(t, {
|
||||
arbiters: { technical: 'lead', delivery: 'chief' },
|
||||
arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', authority: authority.chief },
|
||||
lead: { definition: 'pm', authority: authority.pm },
|
||||
@@ -172,9 +172,9 @@ test('REQ-COS-1: the sender is judged by definition: an instance `lead` of the p
|
||||
|
||||
test('REQ-COS-1: the launcher is judged by definition: an instance `boss` of the chief-of-staff definition receives, a `chief` of the coder definition gives no-chief-of-staff', (t) => {
|
||||
const boss = setup(t, {
|
||||
arbiters: { technical: 'pm', delivery: 'boss' },
|
||||
arbiters: { technical: 'pm', delivery: 'chief' },
|
||||
launch: { by: 'boss', instances: ['coder'], max: { opus: 2, sonnet: 2 } },
|
||||
roles: {
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
boss: { definition: 'chief-of-staff', authority: authority.chief },
|
||||
pm: { definition: 'pm', authority: authority.pm },
|
||||
coder: { definition: 'coder', ...none },
|
||||
|
||||
@@ -91,7 +91,7 @@ test('runtime reads the current organization file for project selections and con
|
||||
doc.plugins = { 'system-type': ['coding'] };
|
||||
writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } };
|
||||
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } };
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: business }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
|
||||
@@ -46,8 +46,8 @@ async function runtime(t) {
|
||||
dataRoot: join(root, 'data'),
|
||||
businesses: {
|
||||
demo: {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: { 'bus.tester': true },
|
||||
roles: { coder: { definition: 'coder', ...none }, assistant: { definition: 'assistant', ...none } },
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', ...none }, assistant: { definition: 'assistant', ...none } },
|
||||
},
|
||||
},
|
||||
organization: { dir: root, rolesDir, system: systemFor(root) },
|
||||
@@ -166,8 +166,8 @@ function broker(t, vars = { 'bus.tester': true }) {
|
||||
store,
|
||||
businesses: {
|
||||
demo: {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'lead', delivery: 'lead' }, ...(vars ? { vars } : {}),
|
||||
roles: { dev: { definition: 'coder', ...none }, lead: { definition: 'pm', ...none } },
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, ...(vars ? { vars } : {}),
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, dev: { definition: 'coder', ...none }, lead: { definition: 'pm', ...none } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Store } from '../src/store.mjs';
|
||||
import { Broker, TASK_VERBS } from '../src/broker.mjs';
|
||||
import { serve } from '../src/server.mjs';
|
||||
import { Client } from '../src/client.mjs';
|
||||
import { refusalGuidance } from '../src/refusals.mjs';
|
||||
import { startBroker } from '../src/runtime.mjs';
|
||||
import { readProcess, verifyHuman } from '../src/human.mjs';
|
||||
import { verifyTester, testerNonce } from '../src/tester.mjs';
|
||||
@@ -387,9 +388,9 @@ test('an envelope carries exactly one of cap, human and tester; no tester authen
|
||||
const cap = b.bindReader({ business: 'demo' });
|
||||
const role = b.bindLaunch({ business: 'demo', role: 'pm', run: 'pm-run', harness: 'pi' });
|
||||
for (const r of [{ tester: p, cap }, { tester: p, cap: role }, { tester: p, human: p }, { tester: p, human: p, cap }])
|
||||
assert.deepEqual(await raw(path, { ...r, verb: 'agents' }), { ok: false, error: 'invalid-envelope' }, Object.keys(r).join('+'));
|
||||
assert.deepEqual(await raw(path, { ...r, verb: 'agents' }), { ok: false, error: 'invalid-envelope', ...refusalGuidance('invalid-envelope') }, Object.keys(r).join('+'));
|
||||
assert.equal(calls, 0);
|
||||
assert.deepEqual(await raw(bare, { tester: p, verb: 'agents' }), { ok: false, error: 'tester-required' });
|
||||
assert.deepEqual(await raw(bare, { tester: p, verb: 'agents' }), { ok: false, error: 'tester-required', ...refusalGuidance('tester-required') });
|
||||
assert.deepEqual(await raw(path, { tester: p, verb: 'agents' }), { ok: true, result: [] });
|
||||
assert.equal(calls, 1);
|
||||
// The tester's capability lasts one request, as the human's does.
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Store } from '../src/store.mjs';
|
||||
import { Broker } from '../src/broker.mjs';
|
||||
import { serve } from '../src/server.mjs';
|
||||
import { Client } from '../src/client.mjs';
|
||||
import { refusalGuidance } from '../src/refusals.mjs';
|
||||
import { views } from '../src/views.mjs';
|
||||
const businesses = {
|
||||
demo: {
|
||||
@@ -72,7 +73,10 @@ test('malformed, oversized and identity-forging envelopes refuse without echoing
|
||||
const out = await raw(input);
|
||||
assert.equal(JSON.parse(out).ok, false);
|
||||
assert.ok(!out.includes('not-json'));
|
||||
assert.ok(out.length < 150);
|
||||
const refusal = JSON.parse(out);
|
||||
assert.ok(['invalid-envelope', 'request-too-large'].includes(refusal.error));
|
||||
assert.deepEqual(refusal, { ok: false, error: refusal.error, ...refusalGuidance(refusal.error) });
|
||||
assert.ok(out.length < 1024); // A bounded static description, independent of input size.
|
||||
}
|
||||
});
|
||||
test('client preserves UTF-8 when a response divides a multibyte character', async (t) => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Broker } from '../src/broker.mjs';
|
||||
test('REQ-LIMIT-4 usage protocol binds run identity, requires a delivered turn and isolates businesses', async t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'usage-broker-'));
|
||||
const store = new Store(root); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const businesses = { demo: { id: 'demo', human: 'jason', roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } }, arbiters: { technical: 'coder', delivery: 'coder' } } };
|
||||
const businesses = { demo: { id: 'demo', human: 'jason', roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } }, arbiters: { technical: 'chief', delivery: 'chief' } } };
|
||||
const recorded = [];
|
||||
const broker = new Broker({ store, businesses, usage: {
|
||||
collect: async (...args) => { recorded.push(args); return { recorded: true }; },
|
||||
|
||||
@@ -12,7 +12,7 @@ test('REQ-PLUG-1 runtime reaches transcript only through loader, over the real b
|
||||
t.after(async () => { await runtime?.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const dir = join(root, 'launches/demo/run1/session'); mkdirSync(dir, { recursive: true });
|
||||
const business = { id: 'demo', human: 'jason', plugins: { 'usage-source': ['transcript'], harness: ['pi'] },
|
||||
roles: { coder: { authority: { withinRole: [], crossRole: [] } } }, arbiters: { technical: 'coder', delivery: 'coder' } };
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } }, arbiters: { technical: 'chief', delivery: 'chief' } };
|
||||
runtime = await startBroker({ dataRoot: root, businesses: { demo: business }, launches: [{
|
||||
// Row 65: the launch names where its harness keeps the session files.
|
||||
business: 'demo', role: 'coder', run: 'run1', harness: 'pi', provider: 'other-provider', model: 'other-model', transcript: dir,
|
||||
|
||||
@@ -65,10 +65,10 @@ test("merge-gate's technical raise is cross-role, goes to the cto, and only merg
|
||||
assert.throws(() => b.authorize(caps['merge-gate'], 'git.merge.protected', { decision: d.id, target: TASK }), /decision-consumed/);
|
||||
});
|
||||
|
||||
test("merge-gate has no message.send, and without a domain its raise goes to the delivery arbiter", (t) => {
|
||||
test("merge-gate has no message.send, and without a domain its merge raise still goes to the technical arbiter", (t) => {
|
||||
const { call, raise } = setup(t);
|
||||
assert.throws(() => call('merge-gate', 'message.send', { to: 'cto', body: 'landed' }), /decision-required/);
|
||||
assert.equal(raise('merge-gate', 'git.merge.protected', { target: TASK }).route_to, 'pm');
|
||||
assert.equal(raise('merge-gate', 'git.merge.protected', { target: TASK }).route_to, 'cto');
|
||||
// Every other action is gated for merge-gate, so an uncited raise goes to the human.
|
||||
const push = raise('merge-gate', 'git.push.working', { domain: 'technical', target: TASK });
|
||||
assert.deepEqual([push.class, push.route_to], ['gated', 'human']);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// Old path of packages/organization/src/agent-levels-write.mjs (rename design r2, stage B).
|
||||
export * from "../../organization/src/agent-levels-write.mjs";
|
||||
@@ -0,0 +1,2 @@
|
||||
// Old path of packages/organization/src/composition.mjs (rename design r2, stage B).
|
||||
export * from "../../organization/src/composition.mjs";
|
||||
@@ -0,0 +1,2 @@
|
||||
// Compatibility path for the organization package's caller-rails projection.
|
||||
export * from "../../organization/src/rails.mjs";
|
||||
@@ -1 +1 @@
|
||||
{"business":"acme","contract":"<ROOT>/roles/coder.md","credentials":{"gitea":{"file":"<ROOT>/secrets/coder-gitea.token","rotateBy":"2099-01-01","service":"gitea"},"vikunja":{"expires":"2099-01-01","file":"<ROOT>/secrets/coder-vikunja.token","service":"vikunja"}},"definition":"coder","holder":null,"instance":"coder","launch":null,"limits":{"authority":{"crossRole":["task.scope.change"],"withinRole":["task.update.assigned","git.push.working","review.request","message.send","record.write"]},"network":"none","tools":["read","write","edit","bash","grep","find","ls"]},"project":"stack","provenance":{"bus.tester":"default","dataRoot":"system","environment":"system","execution.adapter":"system","execution.backend":"system","execution.model":"system","execution.provider":"system","gitea.baseUrl":"business:acme","harness":"business:acme:roles.coder","limits.network":["business:acme:roles.coder"],"tracker.baseUrl":"business:acme","tracker.descendants":"default","tracker.kind":"default","tracker.pollSeconds":"business:acme","tracker.project":"project:stack","tracker.reconcileMinutes":"default"},"skills":["ms-unslop","ms-sdlc","ms-file-read","ms-file-write"],"tracker":{"bot":"bot-acme-coder","botId":5},"vars":{"bus.tester":false,"dataRoot":"<ROOT>/data","environment":"development","execution.adapter":"mock","execution.backend":"docker","execution.model":"glm-5.3-flash","execution.provider":"zai","gitea.baseUrl":"https://git.example","harness":"pi","limits.network":"none","tracker.baseUrl":"http://127.0.0.1:3456","tracker.descendants":false,"tracker.kind":"vikunja","tracker.pollSeconds":60,"tracker.project":3,"tracker.reconcileMinutes":60},"webSource":null}
|
||||
{"business":"acme","contract":"<ROOT>/roles/coder.md","credentials":{"gitea":{"file":"<ROOT>/secrets/coder-gitea.token","rotateBy":"2099-01-01","service":"gitea"},"vikunja":{"expires":"2099-01-01","file":"<ROOT>/secrets/coder-vikunja.token","service":"vikunja"}},"definition":"coder","holder":null,"instance":"coder","launch":null,"limits":{"authority":{"crossRole":["task.scope.change"],"withinRole":["task.update.assigned","git.push.working","review.request","message.send","record.write"]},"network":"none","tools":["read","write","edit","bash","grep","find","ls"]},"project":"stack","provenance":{"bus.tester":"default","dataRoot":"system","environment":"system","execution.adapter":"system","execution.backend":"system","execution.model":"system","execution.provider":"system","gitea.baseUrl":"business:acme","harness":"business:acme:roles.coder","limits.network":["business:acme:roles.coder"],"tracker.baseUrl":"business:acme","tracker.descendants":"default","tracker.kind":"default","tracker.pollSeconds":"business:acme","tracker.project":"project:stack","tracker.reconcileMinutes":"default"},"rails":{"actions":[{"action":"task.update.assigned","routes":[{"to":"alone"}]},{"action":"git.push.working","routes":[{"to":"alone"}]},{"action":"review.request","routes":[{"to":"alone"}]},{"action":"message.send","routes":[{"to":"alone"}]},{"action":"record.write","routes":[{"to":"alone"}]},{"action":"task.scope.change","routes":[{"domain":"delivery","to":"pm"},{"domain":"technical","to":"cto"}]}],"credentials":[{"scopes":["write:issue","write:repository","read:user"],"service":"gitea"},{"scopes":{"projects":["views_buckets_tasks"],"tasks":["read_one","update"],"tasks_comments":["create"]},"service":"vikunja"}],"messaging":null,"network":"none","resolve":[]},"skills":["ms-unslop","ms-sdlc","ms-file-read","ms-file-write"],"tracker":{"bot":"bot-acme-coder","botId":5},"vars":{"bus.tester":false,"dataRoot":"<ROOT>/data","environment":"development","execution.adapter":"mock","execution.backend":"docker","execution.model":"glm-5.3-flash","execution.provider":"zai","gitea.baseUrl":"https://git.example","harness":"pi","limits.network":"none","tracker.baseUrl":"http://127.0.0.1:3456","tracker.descendants":false,"tracker.kind":"vikunja","tracker.pollSeconds":60,"tracker.project":3,"tracker.reconcileMinutes":60},"webSource":null}
|
||||
@@ -67,6 +67,91 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"rails": {
|
||||
"actions": [
|
||||
{
|
||||
"action": "task.update.assigned",
|
||||
"routes": [
|
||||
{
|
||||
"to": "alone"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"action": "git.push.working",
|
||||
"routes": [
|
||||
{
|
||||
"to": "alone"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"action": "review.request",
|
||||
"routes": [
|
||||
{
|
||||
"to": "alone"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"action": "message.send",
|
||||
"routes": [
|
||||
{
|
||||
"to": "alone"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"action": "record.write",
|
||||
"routes": [
|
||||
{
|
||||
"to": "alone"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"action": "task.scope.change",
|
||||
"routes": [
|
||||
{
|
||||
"domain": "delivery",
|
||||
"to": "pm"
|
||||
},
|
||||
{
|
||||
"domain": "technical",
|
||||
"to": "cto"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"resolve": [],
|
||||
"network": "none",
|
||||
"credentials": [
|
||||
{
|
||||
"service": "gitea",
|
||||
"scopes": [
|
||||
"write:issue",
|
||||
"write:repository",
|
||||
"read:user"
|
||||
]
|
||||
},
|
||||
{
|
||||
"service": "vikunja",
|
||||
"scopes": {
|
||||
"tasks": [
|
||||
"read_one",
|
||||
"update"
|
||||
],
|
||||
"tasks_comments": [
|
||||
"create"
|
||||
],
|
||||
"projects": [
|
||||
"views_buckets_tasks"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"messaging": null
|
||||
},
|
||||
"credentials": {
|
||||
"gitea": {
|
||||
"service": "gitea",
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"business":"acme","contract":"/synthetic/roles/coder.md","credentials":{},"definition":"coder","holder":"example","instance":"coder","launch":null,"limits":{"authority":{"crossRole":[],"withinRole":["message.send"]},"network":"none","tools":["read","write"]},"project":"stack","provenance":{"bus.tester":"default","dataRoot":"system","environment":"system","execution.adapter":"system","execution.backend":"system","execution.model":"system","execution.provider":"system","limits.network":["business:acme:roles.coder"],"model":"business:acme:roles.coder","tracker.baseUrl":"business:acme","tracker.descendants":"default","tracker.kind":"default","tracker.pollSeconds":"project:stack:roles.coder","tracker.project":"project:stack","tracker.reconcileMinutes":"default"},"skills":[],"tracker":null,"vars":{"bus.tester":false,"dataRoot":"/synthetic/data","environment":"development","execution.adapter":"mock","execution.backend":"docker","execution.model":"system-model","execution.provider":"zai","limits.network":"none","model":"agent-model","tracker.baseUrl":"http://127.0.0.1:3456","tracker.descendants":false,"tracker.kind":"vikunja","tracker.pollSeconds":15,"tracker.project":3,"tracker.reconcileMinutes":60},"webSource":null}
|
||||
{"business":"acme","contract":"/synthetic/roles/coder.md","credentials":{},"definition":"coder","holder":"example","instance":"coder","launch":null,"limits":{"authority":{"crossRole":[],"withinRole":["message.send"]},"network":"none","tools":["read","write"]},"project":"stack","provenance":{"bus.tester":"default","dataRoot":"system","environment":"system","execution.adapter":"system","execution.backend":"system","execution.model":"system","execution.provider":"system","limits.network":["business:acme:roles.coder"],"model":"business:acme:roles.coder","tracker.baseUrl":"business:acme","tracker.descendants":"default","tracker.kind":"default","tracker.pollSeconds":"project:stack:roles.coder","tracker.project":"project:stack","tracker.reconcileMinutes":"default"},"rails":{"actions":[{"action":"message.send","routes":[{"to":"alone"}]}],"credentials":[],"messaging":null,"network":"none","resolve":[]},"skills":[],"tracker":null,"vars":{"bus.tester":false,"dataRoot":"/synthetic/data","environment":"development","execution.adapter":"mock","execution.backend":"docker","execution.model":"system-model","execution.provider":"zai","limits.network":"none","model":"agent-model","tracker.baseUrl":"http://127.0.0.1:3456","tracker.descendants":false,"tracker.kind":"vikunja","tracker.pollSeconds":15,"tracker.project":3,"tracker.reconcileMinutes":60},"webSource":null}
|
||||
+17
-1
@@ -54,10 +54,26 @@
|
||||
"crossRole": []
|
||||
}
|
||||
},
|
||||
"rails": {
|
||||
"actions": [
|
||||
{
|
||||
"action": "message.send",
|
||||
"routes": [
|
||||
{
|
||||
"to": "alone"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"resolve": [],
|
||||
"network": "none",
|
||||
"credentials": [],
|
||||
"messaging": null
|
||||
},
|
||||
"credentials": {},
|
||||
"tracker": null,
|
||||
"launch": null,
|
||||
"webSource": null,
|
||||
"skills": [],
|
||||
"digest": "975144f5fcd6989c3393175b1713908c632c04f378b5f28a95cb2f4e552fbefa"
|
||||
"digest": "5a8df768f2359fd8c0131eb5e8ce8d427f68d6d465ba20d42b413a280a922ef0"
|
||||
}
|
||||
@@ -117,7 +117,7 @@ test('installed wrapper pins v1 validate and resolve output, preimage and digest
|
||||
'installed-resolve.json': normalized(resolved),
|
||||
'installed-preimage.json': normalized(canonicalJson(preimage)) + '\n',
|
||||
};
|
||||
assert.equal(sha256(snapshots['installed-preimage.json'].slice(0, -1)), '5fc9d6258f4725b0e260d99e5cf81058a05d21b8ff298bb53653efbbc8013da9');
|
||||
assert.equal(sha256(snapshots['installed-preimage.json'].slice(0, -1)), '0a12b35f55d033b91bd89745859028d252be52b05ca4083964b778a27ec20fd6');
|
||||
for (const [name, actual] of Object.entries(snapshots)) {
|
||||
assert.equal(actual, fixture(name), name);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
// #1873 Phase A slice A2: the owner's terminal verbs for the root and project agent levels (design
|
||||
// docs/plans/2026-11-10_agent-levels-design.md sections 6, 7 and 9 item 1).
|
||||
//
|
||||
// mosaic agents root list [--json]
|
||||
// mosaic agents root set NAME [--definition ROLE] [--var KEY=VALUE]... [--var-json KEY=JSON]...
|
||||
// [--unset-var KEY]... [--exclude-orgs ID,ID | --exclude-orgs none] [--revision R]
|
||||
// mosaic agents root remove NAME [--revision R]
|
||||
// mosaic project agents PROJECT --org ID list [--json]
|
||||
// mosaic project agents PROJECT --org ID set NAME --definition ROLE [--var KEY=VALUE]... [--var-json KEY=JSON]... [--revision R]
|
||||
// mosaic project agents PROJECT --org ID narrow NAME --var KEY=VALUE... [--var-json KEY=JSON]... [--revision R]
|
||||
// mosaic project agents PROJECT --org ID exclude NAME [--revision R]
|
||||
// mosaic project agents PROJECT --org ID remove NAME [--revision R]
|
||||
//
|
||||
// Every write is the owner's: an agent run is refused before anything is read (exit 3), and the
|
||||
// verbs run only on a named dev instance, as `mosaic org` does, with the local dev owner as actor.
|
||||
// Without --revision a write reads the revision just before it writes, so a concurrent change
|
||||
// between that read and the write still refuses, but one made before the command started does
|
||||
// not. Pass --revision from a `list` to make the write conditional on what was listed.
|
||||
// A write prints its receipt; refusals name keys, never values.
|
||||
|
||||
import { configDir } from '../../organization/src/organization-config.mjs';
|
||||
import { OrgError } from '../../organization/src/org-registry.mjs';
|
||||
import { OrganizationError, organizationWriter } from '../../organization/src/organization.mjs';
|
||||
import { changeRootAgent, patchRootEntry, projectAgentSummary, readRootAgentsFile, rootAgentSummary } from '../../organization/src/agent-levels-write.mjs';
|
||||
import { loadSystem, rolesDir } from './config.mjs';
|
||||
import { DEV_OWNER, devOwnerInstance } from './dev-local-owner.mjs';
|
||||
import { CliError } from './errors.mjs';
|
||||
import { refuseInsideAgent } from './transport.mjs';
|
||||
|
||||
export const AGENTS_ROOT_USAGE = 'mosaic agents root list [--json] | set NAME [--definition ROLE] [--var KEY=VALUE]... [--var-json KEY=JSON]... [--unset-var KEY]... [--exclude-orgs ID,ID|none] [--revision R] | remove NAME [--revision R]';
|
||||
export const PROJECT_AGENTS_USAGE = 'mosaic project agents PROJECT --org ID list [--json] | set NAME --definition ROLE [--var KEY=VALUE]... [--revision R] | narrow NAME --var KEY=VALUE... [--revision R] | exclude NAME [--revision R] | remove NAME [--revision R]';
|
||||
|
||||
const usage = (text, line) => new CliError(`${text}\nusage: ${line}`, 4);
|
||||
const REPEATED = ['--var', '--var-json', '--unset-var'];
|
||||
const SINGLE = ['--definition', '--exclude-orgs', '--revision', '--org'];
|
||||
|
||||
// Option values are never echoed back: a refusal names the option only.
|
||||
function parse(args, line) {
|
||||
const positional = [], single = {}, repeated = { '--var': [], '--var-json': [], '--unset-var': [] }, flags = new Set();
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a === '--json') { if (flags.has(a)) throw usage('duplicate --json', line); flags.add(a); }
|
||||
else if (REPEATED.includes(a) || SINGLE.includes(a)) {
|
||||
if (i + 1 >= args.length) throw usage(`${a} needs one value`, line);
|
||||
const value = args[++i];
|
||||
if (REPEATED.includes(a)) repeated[a].push(value);
|
||||
else if (Object.hasOwn(single, a)) throw usage(`duplicate ${a}`, line);
|
||||
else single[a] = value;
|
||||
} else if (a.startsWith('--')) throw usage('unknown option', line);
|
||||
else positional.push(a);
|
||||
}
|
||||
return { positional, single, repeated, flags };
|
||||
}
|
||||
|
||||
function only(parsed, allowed, line) {
|
||||
for (const key of [...Object.keys(parsed.single), ...REPEATED.filter(k => parsed.repeated[k].length), ...parsed.flags])
|
||||
if (!allowed.includes(key)) throw usage(`unexpected ${key}`, line);
|
||||
}
|
||||
|
||||
// KEY=VALUE pairs; a --var-json value is parsed as JSON. A malformed pair names its key at most.
|
||||
function vars(parsed, line) {
|
||||
const out = {};
|
||||
const put = (key, value) => {
|
||||
if (!key) throw usage('a var needs KEY=VALUE', line);
|
||||
if (Object.hasOwn(out, key)) throw usage(`var ${key} is given twice`, line);
|
||||
out[key] = value;
|
||||
};
|
||||
for (const pair of parsed.repeated['--var']) {
|
||||
const at = pair.indexOf('=');
|
||||
if (at < 1) throw usage('a var needs KEY=VALUE', line);
|
||||
put(pair.slice(0, at), pair.slice(at + 1));
|
||||
}
|
||||
for (const pair of parsed.repeated['--var-json']) {
|
||||
const at = pair.indexOf('=');
|
||||
if (at < 1) throw usage('a var needs KEY=JSON', line);
|
||||
const key = pair.slice(0, at);
|
||||
try { put(key, JSON.parse(pair.slice(at + 1))); }
|
||||
catch (e) { if (e instanceof CliError) throw e; throw usage(`var ${key} is not valid JSON`, line); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const revisionOf = (parsed, read) => parsed.single['--revision'] ?? read();
|
||||
function owner(io) {
|
||||
refuseInsideAgent(io.env);
|
||||
// Its refusal is one fixed line naming no path.
|
||||
let instance;
|
||||
try { instance = devOwnerInstance(io.env); } catch (error) { throw error instanceof CliError ? error : new CliError(String(error?.message), 2); }
|
||||
// The instance's system config must exist and validate, marker included, as `mosaic org` checks:
|
||||
// a missing or broken config refuses (exit 3) before anything is read or written.
|
||||
loadSystem({ env: io.env, instance });
|
||||
return { dir: configDir(io.env), roles: rolesDir(io.env) };
|
||||
}
|
||||
// Only the writers' own coded refusals print their text (it names keys and ids, never a value).
|
||||
// Anything else, a filesystem error above all, prints its code and a fixed line: its message can
|
||||
// hold a host path. So does a coded refusal whose text happens to hold an absolute path.
|
||||
const ABSOLUTE_PATH = /(?:^|[\s('"=:])\/[^\s/]/;
|
||||
function refusal(error) {
|
||||
if (error instanceof CliError) return error;
|
||||
const code = typeof error?.code === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(error.code) ? error.code : null;
|
||||
const ours = (error instanceof OrgError || error instanceof OrganizationError || error?.name === 'BusinessError') && code !== null;
|
||||
const text = ours && typeof error.message === 'string' && !ABSOLUTE_PATH.test(error.message) ? error.message : 'the operation failed; the error text is not shown';
|
||||
// A refusal after the replace says the change landed; the receipt may be missing.
|
||||
const landed = error?.committed ? ' (the change was written; its receipt may be missing)' : '';
|
||||
return new CliError(`${code ? `${code}: ` : ''}${text}${landed}`, 2);
|
||||
}
|
||||
|
||||
export async function agentsRootCommand(args, io) {
|
||||
const line = AGENTS_ROOT_USAGE;
|
||||
const parsed = parse(args, line);
|
||||
const [op, name, ...extra] = parsed.positional;
|
||||
try {
|
||||
const { dir, roles } = owner(io);
|
||||
if (op === 'list') {
|
||||
if (name !== undefined) throw usage('list takes no name', line);
|
||||
only(parsed, ['--json'], line);
|
||||
const read = await readRootAgentsFile(dir);
|
||||
const listing = { revision: read.revision, state: read.exists ? 'present' : 'absent', agents: rootAgentSummary(read.document) };
|
||||
io.stdout.write(parsed.flags.has('--json') ? `${JSON.stringify(listing, null, 2)}\n` : formatRoot(listing));
|
||||
return;
|
||||
}
|
||||
if (!['set', 'remove'].includes(op) || !name || extra.length) throw usage('root takes list, set NAME or remove NAME', line);
|
||||
only(parsed, op === 'set' ? ['--definition', '--var', '--var-json', '--unset-var', '--exclude-orgs', '--revision'] : ['--revision'], line);
|
||||
let entry = null;
|
||||
if (op === 'set') {
|
||||
const patch = {};
|
||||
if (Object.hasOwn(parsed.single, '--definition')) patch.definition = parsed.single['--definition'];
|
||||
const set = vars(parsed, line);
|
||||
for (const key of parsed.repeated['--unset-var']) {
|
||||
if (Object.hasOwn(set, key)) throw usage(`var ${key} is both set and unset`, line);
|
||||
set[key] = null;
|
||||
}
|
||||
if (Object.keys(set).length) patch.vars = set;
|
||||
if (Object.hasOwn(parsed.single, '--exclude-orgs')) {
|
||||
const text = parsed.single['--exclude-orgs'];
|
||||
patch.excludeOrgs = text === 'none' ? [] : text.split(',');
|
||||
}
|
||||
if (!Object.keys(patch).length) throw usage('set needs --definition, a var or --exclude-orgs', line);
|
||||
entry = old => patchRootEntry(old, patch);
|
||||
}
|
||||
const revision = await revisionOf(parsed, async () => (await readRootAgentsFile(dir)).revision);
|
||||
const receipt = await changeRootAgent(dir, { revision, name, entry }, { actor: DEV_OWNER, owner: DEV_OWNER, rolesDir: roles });
|
||||
io.stdout.write(`${JSON.stringify(receipt)}\n`);
|
||||
} catch (error) { throw refusal(error); }
|
||||
}
|
||||
|
||||
export async function projectAgentsCommand(args, io) {
|
||||
const line = PROJECT_AGENTS_USAGE;
|
||||
const parsed = parse(args, line);
|
||||
const [project, op, name, ...extra] = parsed.positional;
|
||||
try {
|
||||
const { dir, roles } = owner(io);
|
||||
const org = parsed.single['--org'];
|
||||
if (!project || !org) throw usage('project agents needs PROJECT and --org ID', line);
|
||||
const writer = organizationWriter({ dir, rolesDir: roles });
|
||||
if (op === 'list') {
|
||||
if (name !== undefined) throw usage('list takes no name', line);
|
||||
only(parsed, ['--org', '--json'], line);
|
||||
const { document, revision } = writer.read(org);
|
||||
if (!Object.hasOwn(document.projects ?? {}, project)) throw new OrgError('project-not-found', `project ${project} is not in organization ${org}`);
|
||||
const listing = { organization: org, project, revision, agents: projectAgentSummary(document.projects[project].agents ?? {}) };
|
||||
io.stdout.write(parsed.flags.has('--json') ? `${JSON.stringify(listing, null, 2)}\n` : formatProject(listing));
|
||||
return;
|
||||
}
|
||||
if (!['set', 'narrow', 'exclude', 'remove'].includes(op) || !name || extra.length) throw usage('project agents takes list, set, narrow, exclude or remove NAME', line);
|
||||
const allowed = { set: ['--definition', '--var', '--var-json'], narrow: ['--var', '--var-json'], exclude: [], remove: [] }[op];
|
||||
only(parsed, ['--org', '--revision', ...allowed], line);
|
||||
let entry = null;
|
||||
if (op === 'set') {
|
||||
if (!Object.hasOwn(parsed.single, '--definition')) throw usage('set needs --definition', line);
|
||||
const v = vars(parsed, line);
|
||||
entry = { definition: parsed.single['--definition'], ...(Object.keys(v).length ? { vars: v } : {}) };
|
||||
} else if (op === 'narrow') {
|
||||
const v = vars(parsed, line);
|
||||
if (!Object.keys(v).length) throw usage('narrow needs at least one --var', line);
|
||||
entry = { vars: v };
|
||||
} else if (op === 'exclude') entry = { exclude: true };
|
||||
const revisionBefore = revisionOf(parsed, () => writer.read(org).revision);
|
||||
const result = writer.setProjectAgent(org, { revision: revisionBefore, project, name, entry }, DEV_OWNER.sub);
|
||||
// The receipt: names and revisions only. The writer's `previous` is a host path; it isn't printed.
|
||||
const receipt = { verb: 'project.agents.set', organization: org, project, agent: name, form: op === 'set' ? 'define' : op, revisionBefore, revisionAfter: result.revision, transaction: result.transaction };
|
||||
io.stdout.write(`${JSON.stringify(receipt)}\n`);
|
||||
} catch (error) { throw refusal(error); }
|
||||
}
|
||||
|
||||
function formatRoot({ revision, state, agents }) {
|
||||
if (!agents.length) return `root agents: none (${state === 'absent' ? 'no file' : 'empty'}), revision ${revision}\n`;
|
||||
return `root agents (revision ${revision}):\n` + agents.map(a =>
|
||||
` ${a.name}: ${a.definition}${a.model ? `, model ${a.model}` : ''}${a.varKeys.length ? `, vars ${a.varKeys.join(', ')}` : ''}${a.excludeOrgs.length ? `, not in ${a.excludeOrgs.join(', ')}` : ''}\n`).join('');
|
||||
}
|
||||
function formatProject({ organization, project, revision, agents }) {
|
||||
if (!agents.length) return `project ${project} in ${organization}: no project agent entries, revision ${revision}\n`;
|
||||
return `project ${project} in ${organization} (revision ${revision}):\n` + agents.map(a =>
|
||||
` ${a.name}: ${a.form}${a.definition ? ` ${a.definition}` : ''}${a.varKeys?.length ? `, vars ${a.varKeys.join(', ')}` : ''}\n`).join('');
|
||||
}
|
||||
@@ -48,6 +48,7 @@ import { consoleRestart, consoleStart, consoleStatus, consoleStop } from "./org-
|
||||
|
||||
import { usageCommand, USAGE_USAGE } from "./usage.mjs";
|
||||
import { orgCommand, ORG_USAGE } from "./org.mjs";
|
||||
import { agentsRootCommand, AGENTS_ROOT_USAGE, projectAgentsCommand, PROJECT_AGENTS_USAGE } from "./agent-levels.mjs";
|
||||
import { ORG_CONFIG, runConfigCommand } from "../../organization/src/config-command.mjs";
|
||||
import { devCommand, DEV_USAGE, fixtureLaunchEnv, fixtureLauncherOptions } from "./dev-fixture.mjs";
|
||||
import { runCommand, RUN_USAGE } from "./run-evidence.mjs";
|
||||
@@ -65,6 +66,8 @@ export const USAGE = `usage:
|
||||
${ORG_INSTANCES_USAGE}
|
||||
mosaic business validate|resolve|instances|instance|veto: the old spelling of mosaic org config validate|resolve and mosaic org instances|instance|veto, same arguments
|
||||
${PROJECT_USAGE}
|
||||
${PROJECT_AGENTS_USAGE}
|
||||
${AGENTS_ROOT_USAGE}
|
||||
${PROJECT_BUS_USAGE}
|
||||
${NORTH_STAR_USAGE}
|
||||
${INTERVIEW_USAGE}
|
||||
@@ -731,6 +734,9 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
|
||||
if (verb === 'org' && rest[0] === 'config') return orgConfig(rest.slice(1), io);
|
||||
if (verb === 'org' && ["instances", "instance", "veto"].includes(rest[0])) return orgInstancesCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
if (verb === 'org') return orgCommand(rest, io, deps);
|
||||
// A2 (#1873): the owner's root and project agent levels. `mosaic agents` without `root` stays the bus read.
|
||||
if (verb === "agents" && rest[0] === "root") return agentsRootCommand(rest.slice(1), io);
|
||||
if (verb === "project" && rest[0] === "agents") return projectAgentsCommand(rest.slice(1), io);
|
||||
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
|
||||
if (verb === "project" && rest[0] === "north-star") return northStarCommand(rest.slice(1), io, (p) => callFor(p, io, deps));
|
||||
if (verb === "project" && rest[0] === "interview") return interviewCommand(rest.slice(1), io);
|
||||
|
||||
@@ -86,7 +86,7 @@ function trackerFor(system, b, warn, dataRoot) {
|
||||
if (dataRoot && managedState({ dataRoot, business: b.id, project: id, entry }) === 'reserved') continue;
|
||||
if (!existsSync(projectFilePath(entry.root))) continue;
|
||||
const project = loadProject(entry.root);
|
||||
if (project.id !== id) throw new CliError(`project file ${project.file} has id ${project.id}, but business ${b.id} declares it as ${id}`, 3);
|
||||
if (project.id !== id) throw new CliError(`project file ${project.file} has id that does not match the declared project key`, 3);
|
||||
const vars = resolveInstance({ system, business: b, project, instance: first }).vars;
|
||||
if (vars["tracker.project"] !== undefined) named.push({ id, vars });
|
||||
}
|
||||
|
||||
@@ -290,7 +290,7 @@ test("the real human-cli, run from agent ancestry, refuses and writes nothing",
|
||||
const config = join(root, "config");
|
||||
const bus = await startBroker({
|
||||
dataRoot: f.dataRoot,
|
||||
businesses: { acme: { id: "acme", human: "jason", arbiters: { technical: "coder", delivery: "coder" }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } },
|
||||
businesses: { acme: { id: "acme", human: "jason", arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } },
|
||||
organization: { dir: config, rolesDir: f.roles, system: systemFor(root) },
|
||||
});
|
||||
t.after(() => bus.close());
|
||||
@@ -353,7 +353,7 @@ test("T2 a bad user add refuses before any write", async (t) => {
|
||||
}
|
||||
const checked = [
|
||||
[USER_ADD("bob", { subject: "github:bob-sub" }), /provider must be a configured provider/],
|
||||
[USER_ADD("alice", { subject: "authentik:alice-2" }), /duplicate user id: alice/],
|
||||
[USER_ADD("alice", { subject: "authentik:alice-2" }), /duplicate user id/],
|
||||
[USER_ADD("tester"), /a bus principal/],
|
||||
[USER_ADD("jason"), /a business's human/],
|
||||
[USER_ADD("bob", { subject: "authentik:jason-sub" }), /owner sign-in subject/],
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// #1873 A2: `mosaic agents root` and `mosaic project agents`, end to end through scripts/mosaic on a
|
||||
// bootstrapped named instance (the org CLI's own setup, org.test.mjs).
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const repo = resolve(import.meta.dirname, '../../..');
|
||||
const SECRET = 'sk-test-NOT-A-SECRET-1234';
|
||||
const ABSENT = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
|
||||
|
||||
function instance() {
|
||||
const home = mkdtempSync(join(tmpdir(), 'a2cli-'));
|
||||
const config = join(home, '.config/mosaic-atwo/config.json');
|
||||
const env = { PATH: process.env.PATH, LANG: 'C.UTF-8', TMPDIR: process.env.TMPDIR ?? tmpdir(), HOME: home, MOSAIC_CONFIG: config };
|
||||
const boot = spawnSync(join(repo, 'scripts/bootstrap.sh'), ['--instance', 'atwo'], { cwd: repo, env, encoding: 'utf8' });
|
||||
assert.equal(boot.status, 0, boot.stderr);
|
||||
const run = (args, extra = {}) => spawnSync(join(repo, 'scripts/mosaic'), args, { cwd: repo, env: { ...env, ...extra }, encoding: 'utf8' });
|
||||
const ok = (...args) => { const r = run(args); assert.equal(r.status, 0, `${args.join(' ')}: ${r.stderr}`); return r.stdout; };
|
||||
const file = join(home, 'alpha.json');
|
||||
ok('org', 'template', 'render', 'alpha', '--fixture', 'local', '--output', file);
|
||||
ok('org', 'create', 'alpha', '--from', file);
|
||||
const project = Object.keys(JSON.parse(readFileSync(join(home, '.config/mosaic-atwo/businesses/alpha.json'), 'utf8')).projects)[0];
|
||||
return { home, env, run, ok, project, configDir: join(home, '.config/mosaic-atwo') };
|
||||
}
|
||||
|
||||
test('agents root list, set, remove: each write prints its receipt, values stay out of receipts and refusals', () => {
|
||||
const { run, ok, configDir } = instance();
|
||||
assert.deepEqual(JSON.parse(ok('agents', 'root', 'list', '--json')), { revision: ABSENT, state: 'absent', agents: [] });
|
||||
assert.match(ok('agents', 'root', 'list'), /^root agents: none \(no file\)/);
|
||||
const added = JSON.parse(ok('agents', 'root', 'set', 'researcher', '--definition', 'research', '--var', 'model=claude-opus-5-5'));
|
||||
assert.equal(added.verb, 'root.agent.set'); assert.equal(added.agent, 'researcher'); assert.deepEqual(added.changed, ['added']);
|
||||
assert.equal(added.revisionBefore, ABSENT);
|
||||
const patched = run(['agents', 'root', 'set', 'researcher', '--var', 'limits.network=none', '--exclude-orgs', 'home']);
|
||||
assert.equal(patched.status, 0, patched.stderr);
|
||||
assert.deepEqual(JSON.parse(patched.stdout).changed, ['vars.limits.network', 'excludeOrgs']);
|
||||
assert.ok(!patched.stdout.includes('none') && !patched.stdout.includes('claude-opus'));
|
||||
const listed = JSON.parse(ok('agents', 'root', 'list', '--json'));
|
||||
assert.equal(listed.state, 'present');
|
||||
assert.deepEqual(listed.agents, [{ name: 'researcher', definition: 'research', model: 'claude-opus-5-5', varKeys: ['limits.network'], excludeOrgs: ['home'] }]);
|
||||
// A stale --revision refuses and the file is unchanged.
|
||||
const before = readFileSync(join(configDir, 'agents.json'), 'utf8');
|
||||
const stale = run(['agents', 'root', 'set', 'researcher', '--var', 'limits.network=api-only', '--revision', added.revisionAfter]);
|
||||
assert.equal(stale.status, 2); assert.match(stale.stderr, /revision-conflict/);
|
||||
// An invalid value refuses with the key named and the value absent.
|
||||
const bad = run(['agents', 'root', 'set', 'researcher', '--var', `model=${SECRET.repeat(10)}`]);
|
||||
assert.equal(bad.status, 2); assert.match(bad.stderr, /agents-invalid: .*model/); assert.ok(!bad.stderr.includes(SECRET), bad.stderr);
|
||||
const badJson = run(['agents', 'root', 'set', 'researcher', '--var-json', `limits.tools=${SECRET}`]);
|
||||
assert.equal(badJson.status, 4); assert.match(badJson.stderr, /var limits\.tools is not valid JSON/); assert.ok(!badJson.stderr.includes(SECRET));
|
||||
const chief = run(['agents', 'root', 'set', 'boss', '--definition', 'chief-of-staff']);
|
||||
assert.equal(chief.status, 2); assert.match(chief.stderr, /chief-of-staff can't be a root agent/);
|
||||
assert.equal(readFileSync(join(configDir, 'agents.json'), 'utf8'), before);
|
||||
// An agent run is refused before anything is read.
|
||||
for (const marker of ['MOSAIC_RUN_ID', 'MOSAIC_BUS_CAP', 'MOSAIC_AGENT_RUN']) {
|
||||
const inside = run(['agents', 'root', 'remove', 'researcher'], { [marker]: '1' });
|
||||
assert.equal(inside.status, 3, `${marker}: ${inside.stderr}`);
|
||||
}
|
||||
assert.equal(readFileSync(join(configDir, 'agents.json'), 'utf8'), before);
|
||||
const removed = JSON.parse(ok('agents', 'root', 'remove', 'researcher'));
|
||||
assert.equal(removed.verb, 'root.agent.remove'); assert.deepEqual(removed.changed, ['removed']);
|
||||
const receipts = readFileSync(join(configDir, 'agents-receipts.jsonl'), 'utf8');
|
||||
assert.equal(receipts.trim().split('\n').length, 3);
|
||||
assert.ok(!receipts.includes('claude-opus') && !receipts.includes(SECRET));
|
||||
// Usage refusals never echo option values.
|
||||
const unknown = run(['agents', 'root', 'set', 'researcher', `--${SECRET}`]);
|
||||
assert.equal(unknown.status, 4); assert.ok(!unknown.stderr.includes(SECRET));
|
||||
});
|
||||
|
||||
test('a filesystem failure prints its code and a fixed line, never a host path', () => {
|
||||
const { run, configDir, home } = instance();
|
||||
// A directory where the receipt file goes: opening it fails EISDIR, whose message holds the path.
|
||||
mkdirSync(join(configDir, 'agents-receipts.jsonl'));
|
||||
const r = run(['agents', 'root', 'set', 'researcher', '--definition', 'research']);
|
||||
assert.equal(r.status, 2, r.stderr);
|
||||
assert.match(r.stderr, /EISDIR: the operation failed; the error text is not shown/);
|
||||
assert.ok(!r.stderr.includes(home), r.stderr);
|
||||
assert.equal(existsSync(join(configDir, 'agents.json')), false);
|
||||
});
|
||||
|
||||
test('a missing, malformed or unclaimed instance config refuses before anything is read or written', () => {
|
||||
const verbs = [['agents', 'root', 'set', 'researcher', '--definition', 'research'], ['agents', 'root', 'list'], ['project', 'agents', 'work', '--org', 'alpha', 'exclude', 'coder']];
|
||||
const check = (env, dir, why) => {
|
||||
for (const args of verbs) {
|
||||
const r = spawnSync(join(repo, 'scripts/mosaic'), args, { cwd: repo, env, encoding: 'utf8' });
|
||||
assert.equal(r.status, 3, `${why} ${args.join(' ')}: ${r.stderr}`); assert.match(r.stderr, /system config problem/);
|
||||
}
|
||||
assert.equal(existsSync(join(dir, 'agents.json')), false, why);
|
||||
assert.equal(existsSync(join(dir, 'agents-receipts.jsonl')), false, why);
|
||||
};
|
||||
// Missing: the instance's config directory exists, its config.json does not.
|
||||
const home = mkdtempSync(join(tmpdir(), 'a2cfg-'));
|
||||
const dir = join(home, '.config/mosaic-atwo');
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
const env = { PATH: process.env.PATH, LANG: 'C.UTF-8', TMPDIR: process.env.TMPDIR ?? tmpdir(), HOME: home, MOSAIC_CONFIG: join(dir, 'config.json') };
|
||||
check(env, dir, 'missing');
|
||||
writeFileSync(join(dir, 'config.json'), '{', { mode: 0o600 });
|
||||
check(env, dir, 'malformed');
|
||||
// Unclaimed: a bootstrapped instance whose data root marker names another instance.
|
||||
const booted = instance();
|
||||
const { dataRoot } = JSON.parse(readFileSync(booted.env.MOSAIC_CONFIG, 'utf8'));
|
||||
writeFileSync(join(dataRoot, 'instance.json'), JSON.stringify({ instanceVersion: 1, name: 'other' }));
|
||||
check(booted.env, booted.configDir, 'marker');
|
||||
});
|
||||
|
||||
test('the default instance refuses the agent level verbs', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'a2def-'));
|
||||
const env = { PATH: process.env.PATH, LANG: 'C.UTF-8', TMPDIR: process.env.TMPDIR ?? tmpdir(), HOME: home };
|
||||
for (const args of [['agents', 'root', 'list'], ['project', 'agents', 'work', '--org', 'alpha', 'list']]) {
|
||||
const r = spawnSync(join(repo, 'scripts/mosaic'), args, { cwd: repo, env, encoding: 'utf8' });
|
||||
assert.equal(r.status, 2, r.stderr); assert.match(r.stderr, /named instance/);
|
||||
}
|
||||
assert.equal(existsSync(join(home, '.config/mosaic-dev/agents.json')), false);
|
||||
});
|
||||
|
||||
test('project agents: set, narrow, exclude and remove print receipts; a widening narrow and the chief refuse', () => {
|
||||
const { run, ok, project, home } = instance();
|
||||
ok('agents', 'root', 'set', 'researcher', '--definition', 'research');
|
||||
ok('agents', 'root', 'set', 'scout', '--definition', 'research');
|
||||
const p = (...args) => ['project', 'agents', project, '--org', 'alpha', ...args];
|
||||
assert.deepEqual(JSON.parse(ok(...p('list', '--json'))).agents, []);
|
||||
const defined = JSON.parse(ok(...p('set', 'checker', '--definition', 'fact-checker', '--var', 'model=claude-sonnet-5-5')));
|
||||
assert.deepEqual({ ...defined, revisionBefore: undefined, revisionAfter: undefined, transaction: undefined },
|
||||
{ verb: 'project.agents.set', organization: 'alpha', project, agent: 'checker', form: 'define', revisionBefore: undefined, revisionAfter: undefined, transaction: undefined });
|
||||
assert.notEqual(defined.revisionBefore, defined.revisionAfter);
|
||||
assert.ok(!JSON.stringify(defined).includes(home), 'no host path in the receipt');
|
||||
assert.equal(JSON.parse(ok(...p('narrow', 'researcher', '--var', 'limits.network=none'))).form, 'narrow');
|
||||
assert.equal(JSON.parse(ok(...p('exclude', 'scout'))).form, 'exclude');
|
||||
assert.deepEqual(JSON.parse(ok(...p('list', '--json'))).agents, [
|
||||
{ name: 'checker', form: 'define', definition: 'fact-checker', varKeys: ['model'] },
|
||||
{ name: 'researcher', form: 'narrow', varKeys: ['limits.network'] },
|
||||
{ name: 'scout', form: 'exclude' },
|
||||
]);
|
||||
const file = join(home, '.config/mosaic-atwo/businesses/alpha.json');
|
||||
const before = readFileSync(file, 'utf8');
|
||||
const widen = run(p('narrow', 'scout', '--var', `model=${SECRET}`));
|
||||
assert.equal(widen.status, 2); assert.match(widen.stderr, /can only narrow limits\.tools, limits\.network, limits\.authority, not "model"/); assert.ok(!widen.stderr.includes(SECRET));
|
||||
const chief = run(p('exclude', 'chief'));
|
||||
assert.equal(chief.status, 2); assert.match(chief.stderr, /project-agents-invalid/);
|
||||
const stale = run(p('remove', 'checker', '--revision', defined.revisionBefore));
|
||||
assert.equal(stale.status, 2); assert.match(stale.stderr, /revision-conflict/);
|
||||
const inside = run(p('remove', 'checker'), { MOSAIC_RUN_ID: 'r1' });
|
||||
assert.equal(inside.status, 3);
|
||||
assert.equal(readFileSync(file, 'utf8'), before);
|
||||
for (const name of ['checker', 'researcher', 'scout']) assert.equal(JSON.parse(ok(...p('remove', name))).form, 'remove');
|
||||
assert.deepEqual(JSON.parse(ok(...p('list', '--json'))).agents, []);
|
||||
const missing = run(['project', 'agents', 'nope', '--org', 'alpha', 'exclude', 'scout']);
|
||||
assert.equal(missing.status, 2); assert.match(missing.stderr, /project-not-found/);
|
||||
});
|
||||
@@ -100,9 +100,15 @@ function sessionPairs(pairs) {
|
||||
// Safety net for a removed-outer-bound change. It is fixture cleanup, never
|
||||
// product supervision. The guard reads only fixture records and checks sid
|
||||
// immediately before every kill. Normal outer-bound cases return before it.
|
||||
export function fixtureGuard(t,files,milliseconds=9500) {
|
||||
const program=`const fs=require('node:fs');setTimeout(()=>{for(const file of JSON.parse(process.argv[1])){let rows=[];try{rows=fs.readFileSync(file,'utf8').trim().split(String.fromCharCode(10))}catch{}for(const row of rows){const [pid,sid]=row.trim().split(' ').map(Number);if(!(pid>0&&sid>0))continue;try{const raw=fs.readFileSync('/proc/'+pid+'/stat','utf8'),f=raw.slice(raw.lastIndexOf(') ')+2).split(' ');if(Number(f[3])===sid&&f[0]!=='Z')process.kill(pid,'SIGKILL')}catch{}}}},Number(process.argv[2]));`;
|
||||
const child=spawn(process.execPath,['-e',program,JSON.stringify(files),String(milliseconds)],{stdio:'ignore'});
|
||||
export function fixtureGuard(t,files,milliseconds=9500,startFile) {
|
||||
// P10 already records the instant its observed probe starts. Arm its safety
|
||||
// net there, so unrelated host-fixture setup cannot consume the probe budget.
|
||||
// Callers without an observer retain the original immediate-start behavior.
|
||||
const program=`const fs=require('node:fs');
|
||||
function cleanup(){for(const file of JSON.parse(process.argv[1])){let rows=[];try{rows=fs.readFileSync(file,'utf8').trim().split(String.fromCharCode(10))}catch{}for(const row of rows){const [pid,sid]=row.trim().split(' ').map(Number);if(!(pid>0&&sid>0))continue;try{const raw=fs.readFileSync('/proc/'+pid+'/stat','utf8'),f=raw.slice(raw.lastIndexOf(') ')+2).split(' ');if(Number(f[3])===sid&&f[0]!=='Z')process.kill(pid,'SIGKILL')}catch{}}}}
|
||||
function arm(){setTimeout(cleanup,Number(process.argv[2]));}
|
||||
if(!process.argv[3])arm();else{const poll=setInterval(()=>{let text='';try{text=fs.readFileSync(process.argv[3],'utf8')}catch{}if(/^begin [0-9]+[.][0-9]{2}\\n/.test(text)){clearInterval(poll);arm();}},10);}`;
|
||||
const child=spawn(process.execPath,['-e',program,JSON.stringify(files),String(milliseconds),...(startFile?[startFile]:[])],{stdio:'ignore'});
|
||||
const stop=async()=>{
|
||||
if(child.exitCode!==null||child.signalCode!==null)return;
|
||||
const exited=new Promise(resolve=>child.once('exit',resolve));
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fixtureGuard } from './case-sudo-fixture.mjs';
|
||||
|
||||
test('fixture cleanup guard starts its budget at the probe marker', async t => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'guard-'));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const records = join(dir, 'pids'), startFile = join(dir, 'probe');
|
||||
writeFileSync(startFile, '');
|
||||
const child = spawn(process.execPath, ['-e', 'setInterval(()=>{},1000)'], { stdio: 'ignore' });
|
||||
const exited = once(child, 'exit');
|
||||
t.after(() => { if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); });
|
||||
const raw = readFileSync(`/proc/${child.pid}/stat`, 'utf8');
|
||||
const sid = Number(raw.slice(raw.lastIndexOf(') ') + 2).split(' ')[3]);
|
||||
writeFileSync(records, `${child.pid} ${sid}\n`);
|
||||
const stop = fixtureGuard(t, [records], 100, startFile);
|
||||
// An empty marker is setup, not the start of the observed probe. This wait
|
||||
// exceeds the cleanup budget deliberately; no production bound is changed.
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
assert.equal(child.signalCode, null, 'setup must not consume the probe cleanup budget');
|
||||
writeFileSync(startFile, 'begin 100.00\n');
|
||||
const terminal = await Promise.race([exited, new Promise((_, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('armed fixture guard did not clean up')), 3000);
|
||||
timer.unref();
|
||||
})]);
|
||||
assert.deepEqual(terminal, [null, 'SIGKILL']);
|
||||
await stop();
|
||||
});
|
||||
@@ -429,7 +429,7 @@ for(const fault of ['inner','outer','overflow'])test(`row253 C4 P10 ${fault} rea
|
||||
assert.equal(baseline.status,0,baseline.out+baseline.err);
|
||||
assert.deepEqual(await pollFixtureGone(files),[],'baseline leaves no session');
|
||||
}
|
||||
const capture=cleanupFixture(t,files);const stop=fixtureGuard(t,files,7000);
|
||||
const capture=cleanupFixture(t,files);
|
||||
if(fault==='inner')put(join(h.hc,'peer-hang'),'');
|
||||
if(fault==='outer'){put(join(h.hc,'stall-probe'),'');put(join(h.hc,'stall-output'),'200 997 997\n');}
|
||||
if(fault==='overflow'){put(join(h.hc,'peer-output'),'200 997 997\n'+'X'.repeat(256));put(join(h.hc,'peer-status'),'0');}
|
||||
@@ -440,6 +440,7 @@ for(const fault of ['inner','outer','overflow'])test(`row253 C4 P10 ${fault} rea
|
||||
put(join(h.dir,'selector.sh'),read(join(REPO,'scripts','selector.sh')),0o644);
|
||||
assert.equal(removeP10Observers(read(observedScript)),read(SCRIPT),'observer reversal equals a fresh shipped script read');
|
||||
}
|
||||
const stop=fixtureGuard(t,files,7000,timingFile);
|
||||
const start=performance.now(),r=run(h,timingFile?{ROW299_TIMING_FILE:timingFile}:{},undefined,observedScript),seconds=(performance.now()-start)/1000;capture();
|
||||
assert.equal(r.status,0,r.out+r.err);
|
||||
const reason=fault==='inner'?'inner-timeout':fault==='outer'?'outer-timeout':'output-limit';
|
||||
|
||||
@@ -71,7 +71,7 @@ function world(t, { profile = "library" } = {}) {
|
||||
script(join(bin, "stack-copy"), `echo "stack-copy $*" >>'${log}'
|
||||
[ "$1" = copy ] && [ "$3" = --copies ] || exit 4
|
||||
mkdir -p "$4/$2/scripts" && : >"$4/$2/scripts/mosaic" && echo "copied: $4/$2"
|
||||
for file in scripts/case2-persona-import.mjs packages/harness/src/bundle.mjs packages/harness/src/tools.mjs packages/business/src/vocabulary.mjs packages/business/src/selector.mjs packages/organization/src/vocabulary.mjs packages/organization/src/selector.mjs packages/plugins/model-provider/cliproxy/index.mjs; do
|
||||
for file in scripts/case2-persona-import.mjs packages/harness/src/bundle.mjs packages/harness/src/tools.mjs packages/organization/src/rails.mjs packages/bus/src/refusals.mjs packages/business/src/vocabulary.mjs packages/business/src/selector.mjs packages/organization/src/vocabulary.mjs packages/organization/src/selector.mjs packages/plugins/model-provider/cliproxy/index.mjs; do
|
||||
mkdir -p "$4/$2/$(dirname "$file")" && cp '${REPO}'"$file" "$4/$2/$file" || exit 1
|
||||
done
|
||||
mkdir -p "$4/$2/node_modules" && cp -R '${REPO}node_modules/yaml' "$4/$2/node_modules/yaml"`);
|
||||
|
||||
@@ -33,7 +33,7 @@ async function setup(t, { stub = false, hook = null } = {}) {
|
||||
const bus = await startBroker({
|
||||
dataRoot: f.dataRoot,
|
||||
businesses: stub
|
||||
? { acme: { id: "acme", human: "jason", arbiters: { technical: "coder", delivery: "coder" }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } }
|
||||
? { acme: { id: "acme", human: "jason", arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } }
|
||||
: boot.businesses,
|
||||
organization: stub ? { dir, rolesDir: f.roles, system: systemFor(root) } : boot.organization,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
function scratch(t) { const root = mkdtempSync(join(tmpdir(), 'r4-')); t.after(() => rmSync(root, { recursive: true, force: true })); return root; }
|
||||
function safe(label, Type, props = {}, omitted = secret) { return error => {
|
||||
assert.ok(error instanceof Type, error.stack);
|
||||
for (const [key, value] of Object.entries(props)) assert.equal(error[key], value);
|
||||
assert.ok(error.message.includes(label), error.message);
|
||||
assert.doesNotMatch(error.message + error.stack, /sk-test|NOT-A-SECRET|Unexpected token/);
|
||||
assert.ok(!error.message.includes(omitted), error.message);
|
||||
assert.equal(error.cause, undefined);
|
||||
return true;
|
||||
}; }
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fixture } from './helpers.mjs';
|
||||
import { runConfigCommand } from '../../organization/src/config-command.mjs';
|
||||
const repo = join(import.meta.dirname, '../../..');
|
||||
const task = () => ({ taskVersion: 1, id: 'valid', prompt: 'hello' });
|
||||
for (const [field, edit] of [
|
||||
['task id', d => { d.id = secret; }],
|
||||
['taskVersion', d => { d.taskVersion = secret; }],
|
||||
['tool', d => { d.capabilities = { tools: [secret] }; }],
|
||||
['mission id', (d, root) => { d.mission = 'mission.json'; writeFileSync(join(root, d.mission), JSON.stringify({ missionVersion: 1, id: secret, objective: 'hello' })); }],
|
||||
['missionVersion', (d, root) => { d.mission = 'mission.json'; writeFileSync(join(root, d.mission), JSON.stringify({ missionVersion: secret, id: 'valid', objective: 'hello' })); }],
|
||||
['mission tool', (d, root) => { d.mission = 'mission.json'; writeFileSync(join(root, d.mission), JSON.stringify({ missionVersion: 1, id: 'valid', objective: 'hello', capabilities: { tools: [secret] } })); }],
|
||||
]) test(`task validation ${field} omits file value and keeps exit 2`, t => {
|
||||
const root = scratch(t), d = task(); edit(d, root); const file = join(root, 'task.json'); writeFileSync(file, JSON.stringify(d));
|
||||
const r = spawnSync(process.execPath, [join(repo, 'scripts/mosaic-task.mjs'), 'validate', file], { encoding: 'utf8', env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
|
||||
assert.equal(r.status, 2, r.stderr); assert.ok(r.stderr.includes(field), r.stderr); assert.doesNotMatch(r.stderr, /sk-test|NOT-A-SECRET/);
|
||||
});
|
||||
const configCases = [
|
||||
...['configVersion', 'environment', 'dataRoot'].map(k => [k, d => { d[k] = secret; }]),
|
||||
...['backend', 'provider', 'model', 'adapter'].map(k => [`execution.${k}`, d => { d.execution[k] = secret + '\n'; }]),
|
||||
['federation.self', d => { d.federation = { self: secret, peers: [] }; }],
|
||||
['federation.peers[0].id', d => { d.federation = { self: 'self', peers: [{ id: secret, origin: 'https://peer.example', label: 'Peer' }] }; }],
|
||||
['federation.peers[0].origin', d => { d.federation = { self: 'self', peers: [{ id: 'peer', origin: secret, label: 'Peer' }] }; }],
|
||||
];
|
||||
for (const [field, edit] of configCases) test(`config ${field} omits the configured value`, t => {
|
||||
const root = scratch(t), f = fixture(root), d = JSON.parse(readFileSync(f.config, 'utf8')); edit(d); writeFileSync(f.config, JSON.stringify(d));
|
||||
const r = spawnSync(process.execPath, [join(repo, 'scripts/mosaic-config.mjs'), 'validate'], { encoding: 'utf8', env: { ...f.env, PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
|
||||
assert.equal(r.status, 2, r.stderr); assert.ok(r.stderr.includes(field), r.stderr); assert.doesNotMatch(r.stderr, /sk-test|NOT-A-SECRET/);
|
||||
});
|
||||
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/);
|
||||
});
|
||||
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/);
|
||||
});
|
||||
for (const [field, edit] of [
|
||||
['dataRoot', d => { d.dataRoot = `/x/${secret}/../y`; }],
|
||||
['federation.peers[0].id', d => { d.federation = { self: secret.toLowerCase(), peers: [{ id: secret.toLowerCase(), origin: 'https://peer.example', label: 'Peer' }] }; }],
|
||||
]) test(`config ${field} repeated/canonical value is omitted`, t => {
|
||||
const root = scratch(t), f = fixture(root), d = JSON.parse(readFileSync(f.config, 'utf8')); edit(d); writeFileSync(f.config, JSON.stringify(d));
|
||||
const r = spawnSync(process.execPath, [join(repo, 'scripts/mosaic-config.mjs'), 'validate'], { encoding: 'utf8', env: { ...f.env, PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
|
||||
assert.equal(r.status, 2); assert.ok(r.stderr.includes(field), r.stderr); assert.doesNotMatch(r.stderr, /sk-test|NOT-A-SECRET/);
|
||||
});
|
||||
for (const mission of [false, true]) test(`duplicate ${mission ? 'mission' : 'task'} tool omits the validated value`, t => {
|
||||
const root = scratch(t), d = task();
|
||||
if (mission) { d.mission = 'mission.json'; writeFileSync(join(root, d.mission), JSON.stringify({ missionVersion: 1, id: 'valid', objective: 'hello', capabilities: { tools: ['read', 'read'] } })); }
|
||||
else d.capabilities = { tools: ['read', 'read'] };
|
||||
const file = join(root, 'task.json'); writeFileSync(file, JSON.stringify(d));
|
||||
const r = spawnSync(process.execPath, [join(repo, 'scripts/mosaic-task.mjs'), 'validate', file], { encoding: 'utf8', env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
|
||||
assert.equal(r.status, 2); assert.match(r.stderr, /duplicate tool in/); assert.doesNotMatch(r.stderr, /read/);
|
||||
});
|
||||
for (const childStderr of [secret, `mosaic-config: execution.model must match ${secret}`]) test(`config-command does not relay arbitrary child stderr${childStderr.startsWith('mosaic-config:') ? ' after a known field' : ''}`, t => {
|
||||
const root = scratch(t), file = join(root, 'config.json');
|
||||
const code = `import cp from 'node:child_process'; import { syncBuiltinESMExports } from 'node:module';
|
||||
cp.spawnSync = () => ({ status: 2, stderr: ${JSON.stringify(childStderr)}, stdout: '' }); syncBuiltinESMExports();
|
||||
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/);
|
||||
});
|
||||
|
||||
import { bootConfig, loadSystem } from '../src/config.mjs';
|
||||
import { CliError } from '../src/errors.mjs';
|
||||
test('CLI boot project id mismatch omits the configured value and keeps exit 3', 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() }));
|
||||
assert.throws(() => bootConfig({ system: loadSystem({ env: f.env }), businessId: 'acme', env: f.env }), safe(file, CliError, { exitCode: 3 }, secret.toLowerCase()));
|
||||
});
|
||||
@@ -369,8 +369,8 @@ test("a launched pm's request_staff reaches the chief of staff; no other session
|
||||
const bad = await call("pm", `TOOL request_staff ${JSON.stringify({ task_ref: "vikunja:1/2", roles: [{ role: "coder", count: 0 }] })}`);
|
||||
assert.match(bad.body, /invalid-request/);
|
||||
// The chief of staff has no request_staff, and a pm has no launch.
|
||||
assert.equal((await call("chief", 'TOOL request_staff {"task_ref":"vikunja:1/2","roles":[{"role":"coder","count":1}]}')).body, "refused: unknown-tool");
|
||||
assert.equal((await call("pm", 'TOOL launch {"instance":"coder"}')).body, "refused: unknown-tool");
|
||||
assert.equal((await call("chief", 'TOOL request_staff {"task_ref":"vikunja:1/2","roles":[{"role":"coder","count":1}]}')).body.split("\n")[0], "refused: unknown-tool");
|
||||
assert.equal((await call("pm", 'TOOL launch {"instance":"coder"}')).body.split("\n")[0], "refused: unknown-tool");
|
||||
// Nothing was launched by asking: the chief of staff's limits are the same, and sonnet has one place left.
|
||||
assert.deepEqual(readSessions(f.dataRoot).map((s) => s.instance).sort(), ["chief", "pm"]);
|
||||
const launched = await call("chief", 'TOOL launch {"instance":"coder"}');
|
||||
@@ -445,7 +445,7 @@ test("each launch makes sure the workspace has a clone of the project: cloned, f
|
||||
test("a role the broker says holds a merge decision gets land_work, its prompt line, its landing and the git socket; without it, none", { skip, timeout: 30000 }, async (t) => {
|
||||
const asked = [];
|
||||
const held = [{ instance: "chief", decision: "d1", task_ref: "vikunja:1/2" }, { instance: "coder", decision: "d2", task_ref: "vikunja:1/3" }];
|
||||
const answering = (holders) => ({ wrap: (host) => Object.create(host, { op: { value: async (m) => (m.op === "landHolders" ? (asked.push(m), holders) : host.op(m)) } }) });
|
||||
const answering = (holders) => ({ threadTickMs: 3600000, wrap: (host) => Object.create(host, { op: { value: async (m) => (m.op === "landHolders" ? (asked.push(m), holders) : host.op(m)) } }) });
|
||||
const landing = "- Landing: once your merge decision is approved, land_work it with the task.";
|
||||
const read = (f, run, file) => readFileSync(join(f.dataRoot, "launches", "acme", run, file), "utf8");
|
||||
|
||||
|
||||
@@ -867,7 +867,7 @@ test("row 194 D3 item 5: the chief's session hires with its tool and launches it
|
||||
assert.equal(listed.launched, true);
|
||||
// Hire vars set only model and limits.* (N3). harness and thinking are agent-layer route vars the
|
||||
// human sets, so until then the launch refuses no-route.
|
||||
assert.equal(await ask("launch", { instance: "sec-2" }), "refused: no-route");
|
||||
assert.equal((await ask("launch", { instance: "sec-2" })).split("\n")[0], "refused: no-route");
|
||||
const route = { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" };
|
||||
s.call("instances.set", { revision: s.call("instances").revision, instance: "sec-2", op: "change", vars: route });
|
||||
const launched = JSON.parse(await ask("launch", { instance: "sec-2" }));
|
||||
@@ -875,13 +875,13 @@ test("row 194 D3 item 5: the chief's session hires with its tool and launches it
|
||||
assert.deepEqual(s.running("sec-2").map((x) => x.run), [launched.run]);
|
||||
assert.equal(readSessions(p.f.dataRoot).find((x) => x.run === launched.run).launchedBy, "chief");
|
||||
// The schema's pattern guides the model. The host refuses any name its file doesn't list.
|
||||
assert.equal(await ask("launch", { instance: "NOT A NAME" }), "refused: instance-not-listed");
|
||||
assert.equal(await ask("launch", { instance: "reviewer" }), "refused: instance-not-listed");
|
||||
assert.equal((await ask("launch", { instance: "NOT A NAME" })).split("\n")[0], "refused: instance-not-listed");
|
||||
assert.equal((await ask("launch", { instance: "reviewer" })).split("\n")[0], "refused: instance-not-listed");
|
||||
await s.claimed(launched.run);
|
||||
const fired = JSON.parse(await ask("fire", { name: "sec-2", reason: "the review is done" }));
|
||||
assert.ok(fired.transaction, JSON.stringify(fired));
|
||||
await until(() => s.running("sec-2").length === 0, 30000);
|
||||
// A fired instance stays in roles, off the listing, so the chief can't launch it again.
|
||||
assert.equal(s.call("instances").instances["sec-2"].launched, false);
|
||||
assert.equal(await ask("launch", { instance: "sec-2" }), "refused: instance-not-listed");
|
||||
assert.equal((await ask("launch", { instance: "sec-2" })).split("\n")[0], "refused: instance-not-listed");
|
||||
});
|
||||
@@ -90,9 +90,9 @@ async function setup(t, { publishDeadlineMs } = {}) {
|
||||
credentials: { gitea: { store: true, file: join(root, "secrets", "agents", "demo", user, "gitea"), rotateBy: "2099-01-01" } },
|
||||
});
|
||||
const demo = {
|
||||
id: "demo", human: "jason", arbiters: { technical: "reviewer", delivery: "reviewer" },
|
||||
id: "demo", human: "jason", arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
projects: { stack: { root: project, remote: `${gitea.origin}/mosaic/stack` } },
|
||||
roles: {
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
coder: agent("coder"),
|
||||
second: agent("second"),
|
||||
reviewer: { authority: { withinRole: ["message.send"], crossRole: [] } },
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
// JSON.parse quotes a prefix near this fault on Node 26; check partial leaks too.
|
||||
const malformed = secret;
|
||||
function scratch(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'r3-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
return root;
|
||||
}
|
||||
function write(file, text = malformed) {
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
writeFileSync(file, text, { mode: 0o600 });
|
||||
return file;
|
||||
}
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fixture } from './helpers.mjs';
|
||||
const repo = join(import.meta.dirname, '../../..');
|
||||
test('task file JSON refusal preserves exit 2 and file without parser bytes', t => {
|
||||
const file = write(join(scratch(t), 'task.json'));
|
||||
const r = spawnSync(process.execPath, [join(repo, 'scripts/mosaic-task.mjs'), 'validate', file], { encoding: 'utf8', env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
|
||||
assert.equal(r.status, 2, r.stderr);
|
||||
assert.ok(r.stderr.includes(file), r.stderr);
|
||||
assert.match(r.stderr, /task file is not valid JSON/);
|
||||
assert.doesNotMatch(r.stdout + r.stderr, /sk-test|Unexpected token/);
|
||||
});
|
||||
test('task file read refusal retains exit 2, file and filesystem code', t => {
|
||||
const file = write(join(scratch(t), 'task.json'), '{}');
|
||||
// A directory is refused by lstat before the read. Inject a read failure
|
||||
// after that unchanged regular-file gate, including on root-run CI hosts.
|
||||
const preload = `import fs from 'node:fs';
|
||||
const read = fs.readFileSync;
|
||||
fs.readFileSync = function(path, ...args) {
|
||||
if (path === ${JSON.stringify(file)}) throw Object.assign(new Error(${JSON.stringify(secret)}), { code: 'EIO' });
|
||||
return read.call(this, path, ...args);
|
||||
};`;
|
||||
const r = spawnSync(process.execPath, ['--import', 'data:text/javascript,' + encodeURIComponent(preload), join(repo, 'scripts/mosaic-task.mjs'), 'validate', file], { encoding: 'utf8', env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
|
||||
assert.equal(r.status, 2, r.stderr);
|
||||
assert.ok(r.stderr.includes(file), r.stderr);
|
||||
assert.match(r.stderr, /task file is not valid JSON.*\(EIO\)/);
|
||||
assert.doesNotMatch(r.stderr, /sk-test/);
|
||||
});
|
||||
test('case2 precheck business helper omits parser bytes in its BUSINESS fact', t => {
|
||||
const root = scratch(t), f = fixture(root);
|
||||
const file = write(join(root, 'config/businesses/acme.json'));
|
||||
// Execute the production facts helper, without host or credential checks.
|
||||
const shell = readFileSync(join(repo, 'scripts/case2-precheck.sh'), 'utf8');
|
||||
const start = shell.indexOf('facts="$(cd "$REPO" && node --input-type=module -e \'') + 'facts="$(cd "$REPO" && node --input-type=module -e \''.length;
|
||||
assert.ok(start > 100);
|
||||
const end = shell.indexOf("' \"$REPO\" \"$BUSINESS\"", start);
|
||||
assert.ok(end > start);
|
||||
const r = spawnSync(process.execPath, ['--input-type=module', '-e', shell.slice(start, end), repo, 'acme'], { encoding: 'utf8', env: { ...f.env, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR, PATH: process.env.PATH } });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.ok(r.stdout.includes(`BUSINESS\tbad\tcannot read ${file}:`), r.stdout);
|
||||
assert.doesNotMatch(r.stdout + r.stderr, /sk-test|Unexpected token/);
|
||||
});
|
||||
@@ -147,7 +147,7 @@ test("rename r2 B2: org config prints the stage A golden v1 bytes, preimage and
|
||||
}
|
||||
const normalized = (text) => text.split(root).join("<ROOT>").replace(/[a-f0-9]{64}/g, "<DIGEST>");
|
||||
const preimageText = `${normalized(canonicalJson(preimage))}\n`;
|
||||
assert.equal(sha256(preimageText.slice(0, -1)), "5fc9d6258f4725b0e260d99e5cf81058a05d21b8ff298bb53653efbbc8013da9");
|
||||
assert.equal(sha256(preimageText.slice(0, -1)), "0a12b35f55d033b91bd89745859028d252be52b05ca4083964b778a27ec20fd6");
|
||||
assert.equal(normalized(validate), golden("installed-validate.json"));
|
||||
assert.equal(normalized(resolved), golden("installed-resolve.json"));
|
||||
assert.equal(preimageText, golden("installed-preimage.json"));
|
||||
|
||||
@@ -18,8 +18,8 @@ const none = { authority: { withinRole: ["message.send"], crossRole: [] } };
|
||||
const inAgent = (env) => io({ ...env, CLAUDECODE: "1", CLAUDE_CODE_ENTRYPOINT: "sdk-ts" });
|
||||
const bus = (vars = { "bus.tester": true }) => ({
|
||||
acme: {
|
||||
id: "acme", human: "jason", arbiters: { technical: "coder", delivery: "coder" }, ...(vars ? { vars } : {}),
|
||||
roles: { coder: { definition: "coder", ...none }, assistant: { definition: "assistant", ...none } },
|
||||
id: "acme", human: "jason", arbiters: { technical: 'chief', delivery: 'chief' }, ...(vars ? { vars } : {}),
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: "coder", ...none }, assistant: { definition: "assistant", ...none } },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -281,8 +281,8 @@ function mosaic(args, env) {
|
||||
test("row 184: scripts/mosaic runs thread create, threads, talk --thread and thread read as the tester through tester-cli.mjs", async (t) => {
|
||||
const { root, f, org } = await orgFixture(t);
|
||||
const none = { authority: { withinRole: ["message.send"], crossRole: [] } };
|
||||
const businesses = { acme: { id: "acme", human: "jason", arbiters: { technical: "coder", delivery: "coder" }, vars: { "bus.tester": true },
|
||||
roles: { coder: { definition: "coder", ...none }, assistant: { definition: "assistant", ...none } } } };
|
||||
const businesses = { acme: { id: "acme", human: "jason", arbiters: { technical: 'chief', delivery: 'chief' }, vars: { "bus.tester": true },
|
||||
roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { definition: "coder", ...none }, assistant: { definition: "assistant", ...none } } } };
|
||||
const rt = await startBroker({ dataRoot: org.root, businesses, organization: { dir: join(root, ".config", "mosaic-test"), rolesDir: f.roles, system: systemFor(root) } });
|
||||
t.after(() => rt.close());
|
||||
const human = rt.broker.bindHuman({ business: "acme", human: "jason", via: "cli", outsideAgent: true });
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
// exception is <boardDir>/seen.json, which holds Jason's "seen" marks and is
|
||||
// only changed when he clicks; a scan reads it and never rewrites it.
|
||||
|
||||
import { parseJsonFileText } from "../../organization/src/util.mjs";
|
||||
import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync, renameSync } from "node:fs";
|
||||
import { join, basename, isAbsolute, resolve } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
@@ -35,9 +36,9 @@ export function loadConfig(path = defaultConfigPath()) {
|
||||
if (!existsSync(path)) throw new ConfigError(`config not found: ${path}`);
|
||||
let raw;
|
||||
try {
|
||||
raw = JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch (err) {
|
||||
throw new ConfigError(`config is not valid JSON: ${path} (${err.message})`);
|
||||
raw = parseJsonFileText(readFileSync(path, "utf8"), path);
|
||||
} catch (error) {
|
||||
throw new ConfigError(`config is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`);
|
||||
}
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new ConfigError(`config is not an object: ${path}`);
|
||||
if (typeof raw.dataRoot !== "string" || !isAbsolute(raw.dataRoot)) {
|
||||
@@ -227,9 +228,9 @@ export function loadSeen(boardDir) {
|
||||
if (!existsSync(path)) return {};
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch (err) {
|
||||
throw new ConfigError(`seen marks file is not valid JSON: ${path} (${err.message})`);
|
||||
parsed = parseJsonFileText(readFileSync(path, "utf8"), path);
|
||||
} catch (error) {
|
||||
throw new ConfigError(`seen marks file is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`);
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new ConfigError(`seen marks file must be a JSON object: ${path}`);
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
// JSON.parse quotes a prefix near this fault on Node 26; check partial leaks too.
|
||||
const malformed = secret;
|
||||
function scratch(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'r3-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
return root;
|
||||
}
|
||||
function write(file, text = malformed) {
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
writeFileSync(file, text, { mode: 0o600 });
|
||||
return file;
|
||||
}
|
||||
function safe(label, ErrorType, properties = {}) {
|
||||
return error => {
|
||||
assert.ok(error instanceof ErrorType);
|
||||
for (const [key, value] of Object.entries(properties)) assert.equal(error[key], value);
|
||||
assert.ok(error.message.includes(label), error.message);
|
||||
assert.doesNotMatch(error.message, /sk-test|Unexpected token/);
|
||||
assert.doesNotMatch(error.stack, /sk-test/);
|
||||
assert.equal(error.cause, undefined);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
import { ConfigError, loadConfig, loadSeen } from '../src/scan.mjs';
|
||||
for (const kind of ['config', 'seen']) test(`${kind} JSON refusal names only the file`, t => {
|
||||
const root = scratch(t), file = write(join(root, kind === 'config' ? 'config.json' : 'seen.json'));
|
||||
assert.throws(() => kind === 'config' ? loadConfig(file) : loadSeen(root), safe(file, ConfigError));
|
||||
});
|
||||
for (const kind of ['config', 'seen']) test(`${kind} read refusal retains the filesystem code`, t => {
|
||||
const root = scratch(t), file = join(root, kind === 'config' ? 'config.json' : 'seen.json');
|
||||
mkdirSync(file);
|
||||
assert.throws(() => kind === 'config' ? loadConfig(file) : loadSeen(root), error => {
|
||||
safe(file, ConfigError)(error);
|
||||
assert.match(error.message, /\(EISDIR\)$/);
|
||||
assert.doesNotMatch(error.message, /illegal operation/);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@
|
||||
// The per-message budget resets on agent_start, the event pi emits once per
|
||||
// prompt run, so a follow-up message gets a fresh budget.
|
||||
|
||||
import { parseJsonFileText } from "../../organization/src/util.mjs";
|
||||
import { Type } from "typebox";
|
||||
import { TOOLS_ENV, TOOL_DESCRIPTIONS, READ_MAX_LINES, loadToolsConfig, createToolSet, enabledToolNames } from "../src/tools.mjs";
|
||||
import { COMMIT_MESSAGE_MAX, COMMIT_PATHS_MAX, VAULT_PREFIXES } from "../src/git.mjs";
|
||||
@@ -141,9 +142,9 @@ export default function (pi) {
|
||||
if (typeof raw !== "string" || raw.length === 0) throw new Error(`${TOOLS_ENV} is not set; the connector sets it from the binding's tools key`);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
throw new Error(`${TOOLS_ENV} is not valid JSON: ${err.message}`);
|
||||
parsed = parseJsonFileText(raw, TOOLS_ENV);
|
||||
} catch {
|
||||
throw new Error(`${TOOLS_ENV} is not valid JSON`);
|
||||
}
|
||||
const config = loadToolsConfig(parsed);
|
||||
const tools = createToolSet(config);
|
||||
|
||||
Loaded 100 of 204 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user