Compare commits
11
Commits
org-rr
...
org-redact4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3e82efe23 | ||
|
|
c8ff8ea128 | ||
|
|
ddf4f895cd | ||
|
|
7035ce61b3 | ||
|
|
54bf239e32 | ||
|
|
a4eed1e219 | ||
|
|
1e1bbaa6dd | ||
|
|
78bda9d6a4 | ||
|
|
6b39af7888 | ||
|
|
96c900d210 | ||
|
|
307fe928ac |
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';
|
||||
@@ -245,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;
|
||||
}
|
||||
}
|
||||
@@ -6938,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');
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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';",
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 @@
|
||||
// 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 });
|
||||
}
|
||||
|
||||
@@ -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"`);
|
||||
|
||||
@@ -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"}');
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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"));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
// Ids in this file are private to the binding's data directory (0600),
|
||||
// like every other journal.
|
||||
|
||||
import { parseJsonFileText } from "../../organization/src/util.mjs";
|
||||
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { DiscordError } from "./errors.mjs";
|
||||
@@ -104,9 +105,9 @@ export function readApprovals(dir) {
|
||||
for (const [i, line] of readFileSync(path, "utf8").split("\n").entries()) {
|
||||
if (line.length === 0) continue;
|
||||
try {
|
||||
out.push(JSON.parse(line));
|
||||
} catch (err) {
|
||||
throw new DiscordError(`${path}:${i + 1}: not valid JSON (${err.message})`);
|
||||
out.push(parseJsonFileText(line, `${path}:${i + 1}`));
|
||||
} catch {
|
||||
throw new DiscordError(`${path}:${i + 1}: not valid JSON`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
// Absent means no tools and a launch exactly as before. It is a fixed
|
||||
// key: the extension reads it at pi start.
|
||||
|
||||
import { parseJsonFileText } from "../../organization/src/util.mjs";
|
||||
import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
|
||||
import { loadWebConfig } from "./web.mjs";
|
||||
import { loadSetsparkConfig } from "./setspark.mjs";
|
||||
@@ -59,9 +60,9 @@ export function loadDataRoot(path = defaultConfigPath()) {
|
||||
if (!existsSync(path)) throw new DiscordError(`config not found: ${path}`);
|
||||
let raw;
|
||||
try {
|
||||
raw = JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch (err) {
|
||||
throw new DiscordError(`config is not valid JSON: ${path} (${err.message})`);
|
||||
raw = parseJsonFileText(readFileSync(path, "utf8"), path);
|
||||
} catch (error) {
|
||||
throw new DiscordError(`config is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`);
|
||||
}
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new DiscordError(`config is not an object: ${path}`);
|
||||
if (typeof raw.dataRoot !== "string" || !isAbsolute(raw.dataRoot)) throw new DiscordError(`config.dataRoot must be an absolute path: ${path}`);
|
||||
@@ -95,7 +96,7 @@ function onlyKeys(obj, allowed, where) {
|
||||
function requireString(obj, key, where, pattern, what) {
|
||||
const v = obj[key];
|
||||
if (typeof v !== "string" || v.length === 0) throw new DiscordError(`${where}: ${key} must be a non-empty string`);
|
||||
if (pattern && !pattern.test(v)) throw new DiscordError(`${where}: ${key} is not ${what}: ${JSON.stringify(v)}`);
|
||||
if (pattern && !pattern.test(v)) throw new DiscordError(`${where}: ${key} is not ${what}`);
|
||||
return v;
|
||||
}
|
||||
|
||||
@@ -147,7 +148,7 @@ export function validateBinding(raw, where = "binding") {
|
||||
if (!Array.isArray(u.channels) || u.channels.length === 0) throw new DiscordError(`${w}: channels must be a non-empty array of listed channel ids`);
|
||||
allowed = u.channels.map((cid, j) => {
|
||||
if (typeof cid !== "string" || !SNOWFLAKE.test(cid)) throw new DiscordError(`${w}.channels[${j}]: not a Discord snowflake id`);
|
||||
if (!channels.some((c) => c.id === cid)) throw new DiscordError(`${w}.channels[${j}]: ${cid} is not a listed channel`);
|
||||
if (!channels.some((c) => c.id === cid)) throw new DiscordError(`${w}.channels[${j}]: is not a listed channel`);
|
||||
return cid;
|
||||
});
|
||||
if (new Set(allowed).size !== allowed.length) throw new DiscordError(`${w}: duplicate channel id`);
|
||||
@@ -203,7 +204,7 @@ export function validateBinding(raw, where = "binding") {
|
||||
const rname = requireString(r, "name", w, ROOT_NAME, "a root name");
|
||||
const rpath = requireString(r, "path", w);
|
||||
if (!isAbsolute(rpath) || rpath.includes("\0")) throw new DiscordError(`${w}: path must be an absolute path`);
|
||||
if (rpath.split(sep).some((seg) => seg.startsWith(".") && seg.length > 0)) throw new DiscordError(`${w}: path must not have a dot-prefixed segment (${rpath})`);
|
||||
if (rpath.split(sep).some((seg) => seg.startsWith(".") && seg.length > 0)) throw new DiscordError(`${w}: path must not have a dot-prefixed segment`);
|
||||
if (resolve(rpath) === sep || resolve(rpath) === homedir()) throw new DiscordError(`${w}: path must not be the filesystem root or the home directory`);
|
||||
if (r.write !== undefined && r.write !== true && r.write !== false) throw new DiscordError(`${w}: write must be true or false`);
|
||||
let git = null;
|
||||
@@ -292,9 +293,9 @@ export function loadBinding(path) {
|
||||
checkPrivateFile(path, "binding");
|
||||
let raw;
|
||||
try {
|
||||
raw = JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch (err) {
|
||||
throw new DiscordError(`binding is not valid JSON: ${path} (${err.message})`);
|
||||
raw = parseJsonFileText(readFileSync(path, "utf8"), path);
|
||||
} catch (error) {
|
||||
throw new DiscordError(`binding is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`);
|
||||
}
|
||||
return validateBinding(raw, `binding ${path}`);
|
||||
}
|
||||
@@ -342,7 +343,7 @@ function setsparkConfig(raw, where, users) {
|
||||
const approvers = {};
|
||||
for (const u of users) {
|
||||
const name = u.name.trim().toLowerCase();
|
||||
if (Object.hasOwn(approvers, name)) throw new DiscordError(`${where}: two users named ${name}; approver names must be distinct`);
|
||||
if (Object.hasOwn(approvers, name)) throw new DiscordError(`${where}: two users share a name; approver names must be distinct (case-insensitive)`);
|
||||
approvers[name] = u.id;
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -46,7 +46,7 @@ export function createGateway({
|
||||
random = Math.random,
|
||||
properties = { os: process.platform, browser: "mosaic-stack", device: "mosaic-stack" },
|
||||
} = {}) {
|
||||
if (typeof url !== "string" || !url.startsWith("wss://")) throw new DiscordError(`gateway: url must be wss://, got ${JSON.stringify(url)}`, 1);
|
||||
if (typeof url !== "string" || !url.startsWith("wss://")) throw new DiscordError(`gateway: url must be wss://`, 1);
|
||||
if (typeof token !== "string" || token.length === 0) throw new DiscordError("gateway: token required", 1);
|
||||
if (typeof WebSocketImpl !== "function") throw new DiscordError("gateway: WebSocket implementation required", 1);
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
// supervised form for a service unit's pre-start
|
||||
// Directories are 0700, files 0600. Lines are appended, never rewritten.
|
||||
|
||||
import { parseJsonFileText } from "../../organization/src/util.mjs";
|
||||
import {
|
||||
appendFileSync, closeSync, constants, existsSync, fstatSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmdirSync, rmSync,
|
||||
unlinkSync, writeFileSync, writeSync,
|
||||
@@ -47,9 +48,9 @@ function readLines(path) {
|
||||
for (const [i, line] of text.split("\n").entries()) {
|
||||
if (line.length === 0) continue;
|
||||
try {
|
||||
out.push(JSON.parse(line));
|
||||
} catch (err) {
|
||||
throw new DiscordError(`${path}:${i + 1}: not valid JSON (${err.message})`);
|
||||
out.push(parseJsonFileText(line, `${path}:${i + 1}`));
|
||||
} catch {
|
||||
throw new DiscordError(`${path}:${i + 1}: not valid JSON`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
@@ -130,7 +131,8 @@ export function writeTurn(dir, id, record) {
|
||||
}
|
||||
|
||||
export function readTurn(dir, id) {
|
||||
return JSON.parse(readFileSync(turnPath(dir, id), "utf8"));
|
||||
const path = turnPath(dir, id);
|
||||
return parseJsonFileText(readFileSync(path, "utf8"), path, "turn record");
|
||||
}
|
||||
|
||||
export function listTurns(dir) {
|
||||
@@ -138,7 +140,7 @@ export function listTurns(dir) {
|
||||
if (!existsSync(turns)) return [];
|
||||
return readdirSync(turns)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.map((f) => JSON.parse(readFileSync(join(turns, f), "utf8")));
|
||||
.map((f) => parseJsonFileText(readFileSync(join(turns, f), "utf8"), join(turns, f), "turn record"));
|
||||
}
|
||||
|
||||
// The daily ceiling counts admissions on the current UTC date. An admission
|
||||
|
||||
@@ -96,7 +96,7 @@ export function loadSetsparkConfig(raw, where = "setspark") {
|
||||
function loadApprovers(raw, where) {
|
||||
if (!isObject(raw) || Object.keys(raw).length > 64) throw new Error(`${where}: must be an object of at most 64 names`);
|
||||
for (const [name, id] of Object.entries(raw)) {
|
||||
if (name.length === 0 || name.length > 64 || name !== name.trim().toLowerCase() || /[\u0000-\u001f\u007f]/.test(name)) throw new Error(`${where}: ${JSON.stringify(name.slice(0, 64))} must be a trimmed lower-case name`);
|
||||
if (name.length === 0 || name.length > 64 || name !== name.trim().toLowerCase() || /[\u0000-\u001f\u007f]/.test(name)) throw new Error(`${where}: an approver name must be trimmed, lower-case, 1-64 characters, with no control characters`);
|
||||
if (typeof id !== "string" || !SNOWFLAKE.test(id)) throw new Error(`${where}.${name}: must be a Discord user id`);
|
||||
}
|
||||
if (new Set(Object.values(raw)).size !== Object.keys(raw).length) throw new Error(`${where}: one Discord user id under two names`);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
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 { validateBinding } from '../src/binding.mjs';
|
||||
import { createGateway } from '../src/gateway.mjs';
|
||||
import { readTurn, listTurns } from '../src/journal.mjs';
|
||||
import { DiscordError } from '../src/errors.mjs';
|
||||
import { rawBinding } from './helpers.mjs';
|
||||
for (const [field, edit] of [
|
||||
['guildId', d => { d.guildId = secret; }],
|
||||
['name', d => { d.name = secret + '/'; }],
|
||||
['path', d => { d.tools = { roots: [{ name: 'root', path: `/x/.${secret}`, write: false }] }; }],
|
||||
]) test(`binding ${field} refusal omits its configured value`, () => {
|
||||
const d = rawBinding(); edit(d); assert.throws(() => validateBinding(d), safe(field, DiscordError, { exitCode: 2 }));
|
||||
});
|
||||
test('gateway url refusal omits its value', () => assert.throws(() => createGateway({ url: secret, token: 'fake' }), safe('url', DiscordError, { exitCode: 1 })));
|
||||
for (const mode of ['read', 'list']) test(`turn ${mode} parser omits file bytes and keeps SyntaxError`, t => {
|
||||
const root = scratch(t); mkdirSync(join(root, 'turns'));
|
||||
const file = join(root, 'turns', 'turn-1.json'); writeFileSync(file, secret);
|
||||
assert.throws(() => mode === 'read' ? readTurn(root, 'turn-1') : listTurns(root), safe(file, SyntaxError));
|
||||
});
|
||||
|
||||
for (const duplicate of [true, false]) test(`binding approver ${duplicate ? 'duplicate' : 'invalid'} name omits user values`, t => {
|
||||
const root = scratch(t), keyFile = join(root, 'fixture-key'); writeFileSync(keyFile, 'fixture\n', { mode: 0o600 });
|
||||
const name = secret.toLowerCase() + (duplicate ? '' : '\u0001');
|
||||
const users = [{ id: '100000000000000100', name }];
|
||||
if (duplicate) users.push({ id: '100000000000000101', name });
|
||||
const raw = rawBinding({ users, tools: { roots: [{ name: 'docs', path: root }], setspark: { baseUrl: 'https://api.setspark.io', keyFile, principal: 'sage' } } });
|
||||
assert.throws(() => validateBinding(raw), safe(duplicate ? 'users' : 'approvers', DiscordError, { exitCode: 2 }, secret.toLowerCase()));
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
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 { DiscordError } from '../src/errors.mjs';
|
||||
import { loadDataRoot, loadBinding } from '../src/binding.mjs';
|
||||
import { readApprovals } from '../src/approvals.mjs';
|
||||
import { readInboxIds } from '../src/journal.mjs';
|
||||
import extension from '../extension/tools.mjs';
|
||||
import { TOOLS_ENV } from '../src/tools.mjs';
|
||||
for (const [name, read, relative] of [
|
||||
['config', (file) => loadDataRoot(file), 'config.json'],
|
||||
['binding', (file) => loadBinding(file), 'binding.json'],
|
||||
['approvals', (_file, root) => readApprovals(root), 'approvals.jsonl'],
|
||||
['journal', (_file, root) => readInboxIds(root), 'inbox.jsonl'],
|
||||
]) test(`${name} JSON refusal omits parser bytes`, t => {
|
||||
const root = scratch(t), ledger = name === 'approvals' || name === 'journal';
|
||||
const file = write(join(root, relative), ledger ? '{}\n' + malformed : malformed);
|
||||
assert.throws(() => read(file, root), safe(ledger ? `${file}:2` : file, DiscordError, { exitCode: 2 }));
|
||||
});
|
||||
test('tools environment JSON refusal names the config key without its bytes', () => {
|
||||
const previous = process.env[TOOLS_ENV];
|
||||
process.env[TOOLS_ENV] = malformed;
|
||||
try { assert.throws(() => extension({}), safe(TOOLS_ENV, Error)); }
|
||||
finally { if (previous === undefined) delete process.env[TOOLS_ENV]; else process.env[TOOLS_ENV] = previous; }
|
||||
});
|
||||
test('config read refusal retains the filesystem code', t => {
|
||||
const file = scratch(t);
|
||||
assert.throws(() => loadDataRoot(file), error => {
|
||||
safe(file, DiscordError, { exitCode: 2 })(error);
|
||||
assert.match(error.message, /\(EISDIR\)$/);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
@@ -157,7 +157,7 @@ test("setspark config: approvers come from the binding's users, never from the b
|
||||
const b = validateBinding(rawBinding({ users, tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage" } } }));
|
||||
assert.deepEqual(b.tools.setspark.approvers, { jason: "100000000000000100", carmen: "100000000000000101" });
|
||||
assert.throws(() => validateBinding(rawBinding({ users, tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage", approvers: { mallory: "100000000000000199" } } } })), /approvers come from users/);
|
||||
assert.throws(() => validateBinding(rawBinding({ users: [{ id: "100000000000000100", name: "Jason" }, { id: "100000000000000101", name: "jason" }], tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage" } } })), /two users named jason/);
|
||||
assert.throws(() => validateBinding(rawBinding({ users: [{ id: "100000000000000100", name: "Jason" }, { id: "100000000000000101", name: "jason" }], tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage" } } })), /two users share a name/);
|
||||
const withCarmenOut = validateBinding(rawBinding({ users: [users[0]], tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage" } } }));
|
||||
assert.throws(() => reloadDiff(b, withCarmenOut), /tools cannot change/, "pi's approvers are fixed at start, so a user change needs a restart");
|
||||
const channelsOnly = validateBinding(rawBinding({ users: [{ ...users[0], channels: [rawBinding().channels[0].id] }, users[1]], tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage" } } }));
|
||||
|
||||
@@ -14,6 +14,7 @@ import { createHash } from "node:crypto";
|
||||
import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { railsText, ownRails } from "../../organization/src/rails.mjs";
|
||||
import { typedTools, untypedActions } from "./tools.mjs";
|
||||
import { route as defaultProxyRoute } from "../../plugins/model-provider/cliproxy/index.mjs";
|
||||
import { CREATIVE_DEFINITIONS } from "../../organization/src/vocabulary.mjs";
|
||||
@@ -36,7 +37,7 @@ export function webPlan(resolved, harness, builtinWeb) {
|
||||
throw new BundleError(`instance ${resolved.instance}: a ${resolved.definition} role fetches only through the broker, not search-source "harness-builtin"`, "web-source-not-allowed");
|
||||
}
|
||||
if (granted && source === "harness-builtin" && !builtinWeb) {
|
||||
throw new BundleError(`instance ${resolved.instance}: search-source "harness-builtin" has no built-in web tool on ${harness}`);
|
||||
throw new BundleError(`instance ${resolved.instance}: search-source "harness-builtin" has no built-in web tool on the configured harness`);
|
||||
}
|
||||
return { source, builtin: granted && source === "harness-builtin" };
|
||||
}
|
||||
@@ -281,7 +282,7 @@ function prompt({ contract, resolved, session, tools, untyped, builtins, persona
|
||||
...checkoutLines(session.checkouts ?? []),
|
||||
// Row 89 (#1583): the names the harness gives its built-ins, as the manifest lists them.
|
||||
`- Built-in tools: ${builtins.join(", ") || "none"}.`,
|
||||
`- Bus tools: ${tools.map((t) => t.name).join(", ")}. The broker decides every call; a refusal names its reason.`,
|
||||
`- Bus tools: ${tools.map((t) => t.name).join(", ")}. The broker decides every call; a refusal carries its code, the rail it enforces and the next step (plus a reason when available).`,
|
||||
"- Each message to you arrives as one turn. Your final answer goes back to the sender as the reply, so end each turn with the answer itself.",
|
||||
"- You never hold a token. You act on the tracker, the forge and other roles only through the bus tools.",
|
||||
];
|
||||
@@ -306,6 +307,7 @@ function prompt({ contract, resolved, session, tools, untyped, builtins, persona
|
||||
if (named.has("request_staff"))
|
||||
lines.push("- Staff: you don't launch sessions. For intake or a task, request_staff takes exactly one of request or task_ref and names each role with how many. The chief of staff answers by message with the instances it launched, or with the refusal and its reason.");
|
||||
if (untyped.length) lines.push(`- Actions you hold that have no bus tool yet: ${untyped.join(", ")}. Say so instead of working around it.`);
|
||||
lines.push("", railsText(resolved.rails ?? ownRails({ instance: resolved.instance, authority: resolved.limits.authority, network: resolved.limits.network })), "");
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
// capability or the claim stopped working (ended, revoked), 23 the broker
|
||||
// stayed unreachable, 2 a bad run directory.
|
||||
|
||||
import { refusalGuidance } from "../../bus/src/refusals.mjs";
|
||||
import { spawn } from "node:child_process";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { chmodSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, closeSync } from "node:fs";
|
||||
@@ -366,6 +367,7 @@ export function toolServer({ path, tools, client, local }) {
|
||||
answer({ ok: true, result });
|
||||
} catch (e) {
|
||||
answer({ ok: false, error: /^[a-z-]{1,64}$/.test(e.code ?? "") ? e.code : "tool-failed",
|
||||
...refusalGuidance(e.code),
|
||||
...(typeof e.reason === "string" && request.tool === "advance_request" && tool.verb === "request.advance" && e.code === "request-evidence-required"
|
||||
? { reason: e.reason.slice(0, 300) } : tool.verb === "record.write" && e.code === "project-record-invalid" && typeof e.reason === "string"
|
||||
? { reason: e.reason.slice(0, 300) } : {}) });
|
||||
@@ -523,14 +525,20 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
|
||||
process.on("SIGINT", stop);
|
||||
|
||||
let session, cap, policy;
|
||||
let input = "session.json";
|
||||
try {
|
||||
session = { ...DEFAULTS, ...JSON.parse(readFileSync(join(runDir, "session.json"), "utf8")), runDir,
|
||||
proxyFirstTurn: existsSync(join(runDir, "proxy-first-turn-enabled")) };
|
||||
input = "stdin capability";
|
||||
cap = JSON.parse(await readLine(stdin)).cap;
|
||||
if (typeof cap !== "string" || !/^[0-9a-f]{64}$/.test(cap)) throw new Error("no capability on stdin");
|
||||
input = "policy.json";
|
||||
policy = JSON.parse(readFileSync(session.bundle.policy, "utf8"));
|
||||
} catch (e) {
|
||||
log(`runner: ${e.message}`);
|
||||
const reason = e.message === "stdin too large" ? "stdin too large (expected at most 4096 characters)"
|
||||
: e.message === "no capability on stdin" ? "no capability on stdin (expected cap with 64 lowercase hex characters)"
|
||||
: `${input} must be readable JSON with the expected session, capability or policy fields`;
|
||||
log(`runner: ${e.code ? `${e.code}: ` : ""}${reason}`);
|
||||
return EXIT.usage;
|
||||
}
|
||||
if (!landingOk(session.landing)) {
|
||||
@@ -567,7 +575,13 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
|
||||
}
|
||||
log(`runner: ${session.business}/${session.instance} claimed by run ${session.run}`);
|
||||
|
||||
const tools = JSON.parse(readFileSync(session.bundle.tools, "utf8"));
|
||||
let tools;
|
||||
try {
|
||||
tools = JSON.parse(readFileSync(session.bundle.tools, "utf8"));
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) throw new SyntaxError("tools.json must be valid JSON");
|
||||
throw error;
|
||||
}
|
||||
const server = await toolServer({
|
||||
path: session.toolSocket,
|
||||
tools,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
// socket (MOSAIC_TOOL_SOCKET) with `{tool, args}`; the runner checks the
|
||||
// name against this list and forwards the call with its own capability.
|
||||
|
||||
import { refusalGuidance } from "../../bus/src/refusals.mjs";
|
||||
import { connect } from "node:net";
|
||||
import { ACTIONS, ID_PATTERN, THREAD_DEFINITION, CREATIVE_DEFINITIONS } from "../../organization/src/vocabulary.mjs";
|
||||
|
||||
@@ -372,10 +373,11 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project
|
||||
requirement_snapshot: str("Exact approved snapshot ID; omit to read the active snapshot"),
|
||||
}));
|
||||
}
|
||||
add("my_rails", "my_rails", null, "Read only your current actions, routes, resolve levels, network, credential service/scope names and messaging limits. No values or other agents' rails.", obj({}));
|
||||
// mos-1308 / M3: no generic spec, evidence, tracker, decision or write tools. The
|
||||
// C2-08 web grant still supplies broker web reads to companion and research only.
|
||||
if (CREATIVE_DEFINITIONS.includes(resolved.definition))
|
||||
return tools.filter(tool => tool.name === "send_message" || tool.name.startsWith("library_") ||
|
||||
return tools.filter(tool => tool.name === "my_rails" || tool.name === "send_message" || tool.name.startsWith("library_") ||
|
||||
tool.name === "web_search" || tool.name === "web_fetch");
|
||||
return tools;
|
||||
}
|
||||
@@ -429,7 +431,13 @@ export function callTool(path, tool, args = {}, { timeout = 60_000 } = {}) {
|
||||
if (!input.includes("\n")) return;
|
||||
try {
|
||||
const r = JSON.parse(input);
|
||||
r.ok ? end(null, r.result) : end(new Error(tool === "intake_question" && r.error === "outcome-unknown" ? "outcome-unknown" : tool === "launch" && r.error === "instance-running" ? "refused: instance-running: target instance is already running and will receive its waiting messages when it claims; you must not launch it again or relay the notice" : `refused: ${r.error}${typeof r.reason === "string" && tool === "advance_request" && r.error === "request-evidence-required" ? `: ${r.reason.slice(0, 300)}` : tool === "write_record" && r.error === "project-record-invalid" && typeof r.reason === "string" ? `: ${r.reason.slice(0, 300)}` : ""}`));
|
||||
if (r.ok) return end(null, r.result);
|
||||
const guidance = refusalGuidance(r.error);
|
||||
const error = new Error(tool === "intake_question" && r.error === "outcome-unknown" ? "outcome-unknown" : tool === "launch" && r.error === "instance-running" ? "refused: instance-running: target instance is already running and will receive its waiting messages when it claims; you must not launch it again or relay the notice" : `refused: ${r.error}${typeof r.reason === "string" && tool === "advance_request" && r.error === "request-evidence-required" ? `: ${r.reason.slice(0, 300)}` : tool === "write_record" && r.error === "project-record-invalid" && typeof r.reason === "string" ? `: ${r.reason.slice(0, 300)}` : ""}`);
|
||||
error.code = r.error;
|
||||
Object.assign(error, guidance);
|
||||
error.message += `\nRail: ${guidance.rail}\nNext step: ${guidance.nextStep}`;
|
||||
end(error);
|
||||
} catch {
|
||||
end(unknown("tool reply isn't JSON"));
|
||||
}
|
||||
|
||||
@@ -200,7 +200,7 @@ test("a chief-of-staff bundle lists end_run, change_organization and set_name as
|
||||
const r = resolvedFor(t, "chief", (d) => (d.roles.chief.vars = { harness, model: "claude-opus-5-5" }));
|
||||
const b = build(t, r);
|
||||
const prefix = harness === "pi" ? "" : "mcp__mosaic__";
|
||||
assert.deepEqual(b.manifest.typed, ["send_message", "launch", "end_run", "hire", "fire", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"].map((n) => prefix + n), harness);
|
||||
assert.deepEqual(b.manifest.typed, ["send_message", "launch", "end_run", "hire", "fire", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "my_rails"].map((n) => prefix + n), harness);
|
||||
assert.deepEqual(b.manifest.untyped, ["decision.resolve.technical", "decision.resolve.delivery"], harness);
|
||||
assert.match(readFileSync(b.files.prompt, "utf8"), /Bus tools: send_message, launch, end_run, hire, fire, organization_revision, change_organization, set_name, read_spec, write_record, raise_decision, /);
|
||||
assert.match(readFileSync(b.files.prompt, "utf8"), /Actions you hold that have no bus tool yet/);
|
||||
@@ -440,7 +440,7 @@ test("search-source harness-builtin: claude-code gets WebSearch and WebFetch and
|
||||
|
||||
test("search-source harness-builtin is refused for a granted pi role, which has no built-in web tool", (t) => {
|
||||
const r = resolvedFor(t, "coder", (d) => (d.roles.coder.vars = { harness: "pi", model: "claude-sonnet-5-5" }));
|
||||
assert.throws(() => build(t, withSource(granted(r), "harness-builtin")), (e) => e instanceof BundleError && /no built-in web tool on pi/.test(e.message));
|
||||
assert.throws(() => build(t, withSource(granted(r), "harness-builtin")), (e) => e instanceof BundleError && /no built-in web tool on the configured harness/.test(e.message));
|
||||
// Without the grant the source doesn't matter on pi.
|
||||
assert.ok(!build(t, withSource(r, "harness-builtin")).manifest.builtins.some((x) => /web/i.test(x)));
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
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 { EXIT } from '../src/runner.mjs';
|
||||
const runner = join(import.meta.dirname, '../src/runner.mjs');
|
||||
for (const at of ['session.json', 'stdin', 'policy.json']) test(`runner ${at} failure omits parser bytes and keeps usage exit`, t => {
|
||||
const root = scratch(t), policy = join(root, 'policy.json');
|
||||
writeFileSync(join(root, 'session.json'), at === 'session.json' ? secret : JSON.stringify({ bundle: { policy } }));
|
||||
writeFileSync(policy, at === 'policy.json' ? secret : '{}');
|
||||
const r = spawnSync(process.execPath, [runner, root], { encoding: 'utf8', input: at === 'stdin' ? secret + '\n' : JSON.stringify({ cap: 'a'.repeat(64) }) + '\n', env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
|
||||
assert.equal(r.status, EXIT.usage, r.stderr);
|
||||
assert.ok(r.stderr.includes(at), r.stderr);
|
||||
assert.doesNotMatch(r.stdout + r.stderr, /sk-test|NOT-A-SECRET|Unexpected token/);
|
||||
});
|
||||
test('runner keeps a read errno without relaying its message', t => {
|
||||
const root = scratch(t); mkdirSync(join(root, 'session.json'));
|
||||
const r = spawnSync(process.execPath, [runner, root], { encoding: 'utf8', input: '', env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
|
||||
assert.equal(r.status, EXIT.usage); assert.match(r.stderr, /session.json/); assert.match(r.stderr, /runner: EISDIR:/);
|
||||
});
|
||||
test('runner tools parser keeps SyntaxError without file bytes', t => {
|
||||
const root = scratch(t), policy = join(root, 'policy.json'), tools = join(root, 'tools.json');
|
||||
writeFileSync(join(root, 'session.json'), JSON.stringify({ bundle: { policy, tools }, turnEnd: 'exit', harnessEnv: {} }));
|
||||
writeFileSync(policy, '{"needs":[]}'); writeFileSync(tools, secret);
|
||||
// Isolate the claim boundary: this test exercises the real post-claim file parser,
|
||||
// and intentionally never starts a broker, adapter or tool server.
|
||||
const code = `import { Readable } from 'node:stream';
|
||||
import { Client } from ${JSON.stringify(new URL('../../bus/src/client.mjs', import.meta.url).href)};
|
||||
import { main } from ${JSON.stringify(new URL('../src/runner.mjs', import.meta.url).href)};
|
||||
Client.prototype.call = async () => ({});
|
||||
try { await main(${JSON.stringify(root)}, { stdin: Readable.from([JSON.stringify({ cap: 'a'.repeat(64) }) + '\\n']), env: {}, log() {} }); }
|
||||
catch (e) { if (!(e instanceof SyntaxError)) throw e; console.error(e.stack); process.exitCode = 9; }`;
|
||||
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, 9, r.stderr); assert.match(r.stderr, /tools.json/); assert.doesNotMatch(r.stderr, /sk-test|NOT-A-SECRET|Unexpected token/);
|
||||
});
|
||||
@@ -14,9 +14,9 @@ const role = (t, definition, harness = 'pi') => resolvedFor(t, 'bean', d => {
|
||||
d.roles.bean = { definition, credentials: { gitea: { ...d.roles.coder.credentials.gitea } }, vars: { harness, model: 'claude-sonnet-5-5', thinking: 'high' } };
|
||||
});
|
||||
const expected = definition => ['send_message', ...(['companion', 'research'].includes(definition)
|
||||
? ['web_search', 'web_fetch'] : []), ...LOOKUPS, 'library_doc_write', 'library_doc_delete'];
|
||||
? ['web_search', 'web_fetch'] : []), ...LOOKUPS, 'library_doc_write', 'library_doc_delete', 'my_rails'];
|
||||
|
||||
test('M3: per-role snapshots contain eight names, or ten with the C2-08 web grant', t => {
|
||||
test('M3/TI: per-role snapshots contain nine names, or eleven with the C2-08 web grant', t => {
|
||||
for (const definition of CREATIVE) {
|
||||
const tools = typedTools({ resolved: role(t, definition), library: true, tracker: true, arbiter: true });
|
||||
assert.deepEqual(tools.map(t => t.name), expected(definition), definition);
|
||||
@@ -72,7 +72,7 @@ test('bundle flag reaches both harness tool manifests and creative prompt withou
|
||||
|
||||
test('C2-05c scope schemas expand only find/search/get, keeping manuscript and tool names unchanged', t => {
|
||||
const tools = typedTools({ resolved: role(t, 'ideas'), library: true });
|
||||
assert.deepEqual(tools.map(t => t.name), ['send_message', ...LOOKUPS, 'library_doc_write', 'library_doc_delete']);
|
||||
assert.deepEqual(tools.map(t => t.name), ['send_message', ...LOOKUPS, 'library_doc_write', 'library_doc_delete', 'my_rails']);
|
||||
for (const name of ['library_find', 'library_search', 'library_get']) {
|
||||
const tool = tools.find(t => t.name === name);
|
||||
assert.deepEqual(tool.parameters.properties.scope.enum, ['canon', 'playground', 'supporting']);
|
||||
|
||||
@@ -11,12 +11,12 @@ const role = (t, definition) => resolvedFor(t, 'bean', doc => {
|
||||
doc.roles.bean = { definition, credentials: { gitea: { ...doc.roles.coder.credentials.gitea } }, vars: { harness: 'pi', model: 'claude-sonnet-5-5', thinking: 'high' } };
|
||||
});
|
||||
|
||||
test('C2-02 M3: each creative role has eight tools, or ten with its web grant', t => {
|
||||
test('C2-02 M3/TI: each creative role has nine tools, or eleven with its web grant', t => {
|
||||
for (const definition of CREATIVE) {
|
||||
const resolved = role(t, definition), tools = typedTools({ resolved, library: true, tracker: true, arbiter: true });
|
||||
const expected = ['send_message', ...(['companion', 'research'].includes(definition) ? ['web_search', 'web_fetch'] : []), ...LOOKUPS, ...WRITES];
|
||||
const expected = ['send_message', ...(['companion', 'research'].includes(definition) ? ['web_search', 'web_fetch'] : []), ...LOOKUPS, ...WRITES, 'my_rails'];
|
||||
assert.deepEqual(tools.map(tool => tool.name), expected, definition);
|
||||
assert.equal(tools.length, ['companion', 'research'].includes(definition) ? 10 : 8);
|
||||
assert.equal(tools.length, ['companion', 'research'].includes(definition) ? 11 : 9);
|
||||
assert.equal(untypedActions(resolved, tools).includes('library.write'), false);
|
||||
}
|
||||
});
|
||||
@@ -56,5 +56,5 @@ test('C2-02 tools: no library, noncreative or no within-role write grant exposes
|
||||
const narrowed = structuredClone(creative);
|
||||
narrowed.limits.authority.withinRole = narrowed.limits.authority.withinRole.filter(action => action !== 'library.write');
|
||||
narrowed.limits.authority.crossRole.push('library.write');
|
||||
assert.deepEqual(typedTools({ resolved: narrowed, library: true }).map(tool => tool.name), ['send_message', ...LOOKUPS]);
|
||||
assert.deepEqual(typedTools({ resolved: narrowed, library: true }).map(tool => tool.name), ['send_message', ...LOOKUPS, 'my_rails']);
|
||||
});
|
||||
@@ -68,7 +68,7 @@ test("tools/call goes through the tool socket; a refusal is an isError result",
|
||||
assert.deepEqual(JSON.parse(ok.content[0].text), { tool: "list_agents", args: { scope: "all" } });
|
||||
const refused = (await server.send("tools/call", { name: "launch", arguments: { instance: "coder" } })).result;
|
||||
assert.equal(refused.isError, true);
|
||||
assert.equal(refused.content[0].text, "refused: capacity-full");
|
||||
assert.match(refused.content[0].text, /^refused: capacity-full\nRail: .+\nNext step: .+/);
|
||||
assert.deepEqual(sock.calls.map((c) => c.tool), ["list_agents", "launch"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { buildBundle } from '../src/bundle.mjs';
|
||||
import { typedTools } from '../src/tools.mjs';
|
||||
import { resolvedFor, scratch, HARNESS, REPO_SKILLS } from './helpers.mjs';
|
||||
import { ownRails, railsText, decisionAutonomy } from '../../organization/src/rails.mjs';
|
||||
|
||||
const SECRET = 'sk-test-NOT-A-SECRET-1234';
|
||||
test('coder prompt lists every held action with its route and only service/scope names', t => {
|
||||
const resolved = resolvedFor(t, 'coder');
|
||||
const root = scratch(t), workspace = join(root, 'ws'); mkdirSync(workspace);
|
||||
const bundle = buildBundle({ dir: join(root, 'bundle'), resolved,
|
||||
contract: '# Contract', harness: HARNESS.pi, skillsDir: REPO_SKILLS,
|
||||
session: { run: 'r1', workspace, toolSocket: join(root, 'tool.sock'), turnMarker: join(root, 'turn.done') } });
|
||||
const prompt = readFileSync(bundle.files.prompt, 'utf8');
|
||||
assert.ok(prompt.includes(railsText(resolved.rails)));
|
||||
for (const action of resolved.limits.authority.withinRole) assert.ok(prompt.includes(`- ${action}: alone.`));
|
||||
assert.match(prompt, /task.scope.change: delivery \(default\): arbiter instance "pm"; technical: arbiter instance "cto"/);
|
||||
assert.match(prompt, /Network: none/);
|
||||
assert.match(prompt, /Credential service gitea; scopes:.*write:repository/);
|
||||
assert.ok(!railsText(resolved.rails).includes(resolved.credentials.gitea.file));
|
||||
assert.ok(!railsText(resolved.rails).includes(SECRET));
|
||||
assert.match(prompt, /a refusal carries its code, the rail it enforces and the next step/);
|
||||
});
|
||||
|
||||
test('all roles including creative roles receive a parameterless caller-only my_rails tool', t => {
|
||||
for (const instance of ['coder', 'chief', 'reviewer']) {
|
||||
const tool = typedTools({ resolved: resolvedFor(t, instance) }).find(t => t.name === 'my_rails');
|
||||
assert.equal(tool.verb, 'my_rails');
|
||||
assert.deepEqual(tool.parameters, { type: 'object', properties: {}, required: [], additionalProperties: false });
|
||||
}
|
||||
const creative = typedTools({ resolved: { ...resolvedFor(t, 'coder'), definition: 'companion' } });
|
||||
assert.ok(creative.some(t => t.name === 'my_rails'));
|
||||
});
|
||||
|
||||
test('resolve rights name their classes and fixed levels through the AU extension point', t => {
|
||||
const { rails } = resolvedFor(t, 'cto');
|
||||
assert.deepEqual(rails.resolve, [{ right: 'decision.resolve.technical', classes: [
|
||||
{ name: 'cross-role', level: decisionAutonomy('cross-role') }, { name: 'merge', level: decisionAutonomy('merge') },
|
||||
] }]);
|
||||
assert.match(railsText(rails), /merge: out of the loop/);
|
||||
});
|
||||
|
||||
test('fixed domains, arbiter self-raises and hard waits describe existing routes only', () => {
|
||||
const rails = ownRails({ instance: 'cto', authority: { withinRole: ['deploy'],
|
||||
crossRole: ['git.merge.protected', 'policy.change'] }, arbiters: { technical: 'cto', delivery: 'pm' } });
|
||||
assert.deepEqual(rails.actions, [
|
||||
{ action: 'deploy', routes: [{ to: 'human' }] },
|
||||
{ action: 'git.merge.protected', routes: [{ domain: 'technical', to: 'human' }] },
|
||||
{ action: 'policy.change', routes: [{ domain: 'delivery', to: 'pm' }] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('hire and fire are unavailable to a chief that is not the configured launcher', t => {
|
||||
const { rails } = resolvedFor(t, 'chief', doc => { delete doc.launch; });
|
||||
for (const action of ['role.hire', 'role.fire']) {
|
||||
assert.deepEqual(rails.actions.find(a => a.action === action).routes, [{ to: 'unavailable' }]);
|
||||
}
|
||||
assert.match(railsText(rails), /role.hire: unavailable here; only the configured chief can hire or fire/);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
// JSON.parse quotes a prefix near this fault on Node 26; check partial leaks too.
|
||||
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 { BundleError, webPlan } from '../src/bundle.mjs';
|
||||
test('harness web refusal names the harness field without the configured value', () => {
|
||||
assert.throws(() => webPlan({ instance: 'builder', definition: 'coder', webSource: 'harness-builtin', limits: { tools: ['web'] } }, secret, false), safe('harness', BundleError, { code: 'bundle-refused' }));
|
||||
});
|
||||
@@ -392,7 +392,12 @@ test('advance_request exposes the named task evidence refusal to the PM tool', a
|
||||
const { Client } = await import('../../bus/src/client.mjs');
|
||||
const server = await runner.toolServer({ path, tools: [{ name: 'advance_request', verb: 'request.advance' }, { name: 'other_tool', verb: 'request.advance' }], local: {}, client: new Client({ path: busPath, cap: 'fixture' }) });
|
||||
t.after(() => new Promise(resolve => server.close(resolve)));
|
||||
await assert.rejects(callTool(path, 'advance_request', {}), { message: `refused: request-evidence-required: ${reason.slice(0, 300)}` });
|
||||
await assert.rejects(callTool(path, 'advance_request', { other: true }), { message: 'refused: project-record-invalid' });
|
||||
await assert.rejects(callTool(path, 'other_tool', {}), { message: 'refused: request-evidence-required' });
|
||||
const refusal = message => error => {
|
||||
assert.equal(error.message.split('\n')[0], message);
|
||||
assert.match(error.message, /\nRail: .+\nNext step: .+/);
|
||||
return true;
|
||||
};
|
||||
await assert.rejects(callTool(path, 'advance_request', {}), refusal(`refused: request-evidence-required: ${reason.slice(0, 300)}`));
|
||||
await assert.rejects(callTool(path, 'advance_request', { other: true }), refusal('refused: project-record-invalid'));
|
||||
await assert.rejects(callTool(path, 'other_tool', {}), refusal('refused: request-evidence-required'));
|
||||
});
|
||||
@@ -477,10 +477,23 @@ test("typed tools carry the runner's capability; launch goes to the host's launc
|
||||
ctx.call(ctx.pm, "message.send", { to: "coder", body: 'TOOL resolve_decision {"id":"x"}' });
|
||||
ctx.call(ctx.pm, "message.send", { to: "coder", body: 'TOOL send_message {"to":"nobody","body":"x"}' });
|
||||
await until(() => replies(ctx)() && ctx.inbox.length >= 4);
|
||||
assert.deepEqual(ctx.inbox.map((m) => m.body), ["refused: capacity-full", '{"run":"new-run"}', "refused: unknown-tool", "refused: unknown-role"]);
|
||||
assert.deepEqual(ctx.inbox.map((m) => m.body.split("\n")[0]), ["refused: capacity-full", '{"run":"new-run"}', "refused: unknown-tool", "refused: unknown-role"]);
|
||||
assert.deepEqual(ctx.launches, [{ cap: ctx.cap, instance: "coder" }, { cap: ctx.cap, instance: "reviewer" }]);
|
||||
});
|
||||
|
||||
test("my_rails reaches the live broker with the runner's own capability and refuses an identity selector", async t => {
|
||||
const ctx = await setup(t, { tools: typedTools({ resolved: resolvedFor(t, "coder") }) });
|
||||
ctx.inbox = [];
|
||||
const r = startRunner(ctx.runDir, ctx.cap);
|
||||
t.after(() => r.child.kill("SIGKILL"));
|
||||
ctx.call(ctx.pm, "message.send", { to: "coder", body: 'TOOL my_rails {}' });
|
||||
await until(() => replies(ctx)() && ctx.inbox.length >= 1);
|
||||
assert.deepEqual(JSON.parse(ctx.inbox[0].body), ctx.call(ctx.cap, "my_rails", {}));
|
||||
ctx.call(ctx.pm, "message.send", { to: "coder", body: 'TOOL my_rails {"role":"cto"}' });
|
||||
await until(() => replies(ctx)() && ctx.inbox.length >= 2);
|
||||
assert.match(ctx.inbox[1].body, /^refused: invalid-request\nRail: .+\nNext step: .+/);
|
||||
});
|
||||
|
||||
// Row 132 (#1628, REQ-ROLE-2, REQ-COS-1, REQ-LIMIT-1): the chief's two tools reach the broker by their verbs
|
||||
// with the arguments the session gave, and a refusal comes back with the broker's code.
|
||||
// Row 134 (#1669): organization_revision reaches the broker's organization.revision and answers the revision alone.
|
||||
@@ -509,7 +522,7 @@ test("end_run, organization_revision and change_organization reach the broker wi
|
||||
send("change_organization", { ...change, surprise: 1 });
|
||||
send("change_organization", { ...change, revision: "e".repeat(64) });
|
||||
await until(() => replies(ctx)() && ctx.inbox.length >= 8);
|
||||
assert.deepEqual(ctx.inbox.map((m) => m.body), [
|
||||
assert.deepEqual(ctx.inbox.map((m) => m.body.split("\n")[0]), [
|
||||
'{"revoked":true}',
|
||||
"refused: role-end-refused",
|
||||
"refused: not-held",
|
||||
@@ -541,7 +554,7 @@ test("set_name reaches the broker's name.set with the tool's arguments, and refu
|
||||
send({ instance: "coder", name: "rex" });
|
||||
send({ instance: "coder", name: "pm" });
|
||||
await until(() => replies(ctx)() && ctx.inbox.length >= 2);
|
||||
assert.deepEqual(ctx.inbox.map((m) => m.body), [
|
||||
assert.deepEqual(ctx.inbox.map((m) => m.body.split("\n")[0]), [
|
||||
'{"instance":"coder","name":"rex"}',
|
||||
"refused: name-is-instance",
|
||||
]);
|
||||
|
||||
@@ -18,7 +18,7 @@ test("the PM gets its task verbs and the reads, and no launch", (t) => {
|
||||
"intake_status", "intake_projects", "intake_open", "intake_question", "intake_propose", "deliver_request", "advance_request", "submit_prd",
|
||||
"task_create", "task_assign", "task_reassign", "task_schedule", "task_priority_change", "task_scope_change",
|
||||
"task_update_assigned", "task_close",
|
||||
"read_spec", "write_record", "raise_decision", "resolve_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "list_requirements",
|
||||
"read_spec", "write_record", "raise_decision", "resolve_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "list_requirements", "my_rails",
|
||||
]);
|
||||
// Row 119 (#1613): only the chief of staff launches (REQ-COS-1).
|
||||
assert.equal(pm.launch, null);
|
||||
@@ -47,7 +47,7 @@ test("send_message offers the BLOCK class, beside the classes it already had", (
|
||||
test("the chief of staff gets launch, send_message, end_run, hire, fire, change_organization and set_name, and no task verbs", (t) => {
|
||||
const chief = resolvedFor(t, "chief");
|
||||
const tools = typedTools({ resolved: chief, tracker: true });
|
||||
assert.deepEqual(names(tools), ["send_message", "launch", "end_run", "hire", "fire", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
|
||||
assert.deepEqual(names(tools), ["send_message", "launch", "end_run", "hire", "fire", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "my_rails"]);
|
||||
const launch = tools.find((x) => x.name === "launch");
|
||||
assert.equal(launch.verb, "launch");
|
||||
assert.equal(launch.action, "role.launch");
|
||||
@@ -225,7 +225,7 @@ test("no role's tools send instances or instances.set, and change_organization t
|
||||
test("the assistant gets send_message, the reads and the web tools (it holds the web grant), no launch", (t) => {
|
||||
const assistant = resolvedFor(t, "assistant", (d) => (d.roles.assistant = { definition: "assistant", holder: "mos" }));
|
||||
const tools = typedTools({ resolved: assistant, tracker: true });
|
||||
assert.deepEqual(names(tools), ["send_message", "web_search", "web_fetch", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
|
||||
assert.deepEqual(names(tools), ["send_message", "web_search", "web_fetch", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "my_rails"]);
|
||||
assert.equal(assistant.launch, null);
|
||||
assert.deepEqual(untypedActions(assistant, tools), []);
|
||||
});
|
||||
@@ -260,7 +260,7 @@ test("list_requirements reads the requirement ids, for a task.create holder with
|
||||
test("a coder gets no launch, no resolve_decision, and no task tools without a tracker", (t) => {
|
||||
const coder = resolvedFor(t, "coder");
|
||||
const tools = typedTools({ resolved: coder });
|
||||
assert.deepEqual(names(tools), ["send_message", "request_review", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail"]);
|
||||
assert.deepEqual(names(tools), ["send_message", "request_review", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "my_rails"]);
|
||||
// Held actions without a tool are listed, so the manifest shows them. With
|
||||
// no clone to push from, push_working is one of them.
|
||||
assert.deepEqual(untypedActions(coder, tools), ["task.update.assigned", "git.push.working", "task.scope.change"]);
|
||||
@@ -270,7 +270,7 @@ test("a coder with a tracker gets its own task tools and no task_reassign", (t)
|
||||
const coder = resolvedFor(t, "coder");
|
||||
assert.deepEqual(names(typedTools({ resolved: coder, tracker: true })), [
|
||||
"send_message", "task_scope_change", "task_update_assigned", "request_review",
|
||||
"read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task",
|
||||
"read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "my_rails",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -283,7 +283,7 @@ test("launch only when the business's launch block names the instance as launche
|
||||
|
||||
test("an action outside the instance's authority has no tool", (t) => {
|
||||
const coder = resolvedFor(t, "coder", (d) => (d.roles.coder.vars["limits.authority"] = ["review.request"]));
|
||||
assert.deepEqual(names(typedTools({ resolved: coder, tracker: true, projects: ["stack"] })), ["request_review", "read_spec", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
|
||||
assert.deepEqual(names(typedTools({ resolved: coder, tracker: true, projects: ["stack"] })), ["request_review", "read_spec", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "my_rails"]);
|
||||
});
|
||||
|
||||
// Row 93 (#1587): the review loop's tools, by authority, and the git ones only with a clone.
|
||||
@@ -292,7 +292,7 @@ test("the review loop: push_working and request_review for the coder, fetch_revi
|
||||
const tools = typedTools({ resolved: coder, tracker: true, projects: ["stack", "site"] });
|
||||
assert.deepEqual(names(tools), [
|
||||
"send_message", "task_scope_change", "task_update_assigned", "push_working", "request_review",
|
||||
"read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task",
|
||||
"read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "my_rails",
|
||||
]);
|
||||
assert.deepEqual(untypedActions(coder, tools), []);
|
||||
const push = tools.find((x) => x.name === "push_working");
|
||||
@@ -309,7 +309,7 @@ test("the review loop: push_working and request_review for the coder, fetch_revi
|
||||
|
||||
const reviewer = resolvedFor(t, "reviewer");
|
||||
const rtools = typedTools({ resolved: reviewer, tracker: true, projects: ["stack"] });
|
||||
assert.deepEqual(names(rtools), ["send_message", "task_update_assigned", "fetch_review", "review_verdict", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
|
||||
assert.deepEqual(names(rtools), ["send_message", "task_update_assigned", "fetch_review", "review_verdict", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "my_rails"]);
|
||||
const fetch = rtools.find((x) => x.name === "fetch_review");
|
||||
assert.deepEqual([fetch.verb, fetch.action], ["working.fetch", null]);
|
||||
assert.deepEqual(fetch.parameters.required, ["task_ref", "request"]);
|
||||
|
||||
@@ -62,14 +62,20 @@ for (const [name, value, detail] of [
|
||||
});
|
||||
}
|
||||
|
||||
test('agent sees validation detail while other refusal codes remain code-only', async t => {
|
||||
test('agent sees validation detail and static guidance without unrelated refusal reasons', async t => {
|
||||
const f = await setup(t);
|
||||
await assert.rejects(f.write('requirements/REQ-1.json', { ...requirement, title: '' }),
|
||||
/refused: project-record-invalid: requirement\.title: too short/);
|
||||
await assert.rejects(f.write('spec/secret-marker.md', '# text'), { message: 'refused: project-spec-human-only' });
|
||||
const refusal = message => error => {
|
||||
assert.equal(error.message.split('\n')[0], message);
|
||||
assert.match(error.message, /\nRail: .+\nNext step: .+/);
|
||||
assert.doesNotMatch(error.message, /secret-marker/);
|
||||
return true;
|
||||
};
|
||||
await assert.rejects(f.write('spec/secret-marker.md', '# text'), refusal('refused: project-spec-human-only'));
|
||||
const path = join(f.root, 'other-tools.sock');
|
||||
const server = await toolServer({ path, tools: [{ name: 'write_record', verb: 'record.write' }], local: {},
|
||||
client: { call() { throw Object.assign(new Error('hidden'), { code: 'project-busy', reason: 'secret-marker' }); } } });
|
||||
t.after(() => server.close());
|
||||
await assert.rejects(callTool(path, 'write_record', {}), { message: 'refused: project-busy' });
|
||||
await assert.rejects(callTool(path, 'write_record', {}), refusal('refused: project-busy'));
|
||||
});
|
||||
@@ -65,6 +65,9 @@ function fixture(t) {
|
||||
cpSync(source, path.join(root, 'packages/ledger/src'), { recursive: true });
|
||||
// The queue section imports the queue validator and the seat registry.
|
||||
for (const pkg of ['queue', 'seat']) cpSync(path.join(source, '../..', pkg, 'src'), path.join(root, 'packages', pkg, 'src'), { recursive: true });
|
||||
mkdirSync(path.join(root, 'packages/organization/src'), { recursive: true });
|
||||
for (const file of ['util.mjs', 'errors.mjs', 'vocabulary.mjs'])
|
||||
cpSync(path.join(source, '../../organization/src', file), path.join(root, 'packages/organization/src', file));
|
||||
// The fake serves ISSUES for every page, or page N of the ISSUE_PAGES array
|
||||
// (an empty page past its end). API_FAIL fails every call, or only FAIL_PAGE.
|
||||
const api = put('bin/gitea-api.sh', `#!/usr/bin/env node
|
||||
|
||||
@@ -337,6 +337,9 @@ function cliFixture(t, rows = MAP) {
|
||||
const repo = scratchRepo(t, { map: mapText(rows, { retired: [], highWater: Math.max(...rows.map(r => r.id)) }) });
|
||||
genesisCommitted(repo);
|
||||
for (const pkg of ['ledger', 'seat']) cpSync(path.join(PACKAGES, pkg, 'src'), path.join(repo.root, 'packages', pkg, 'src'), { recursive: true });
|
||||
mkdirSync(path.join(repo.root, 'packages/organization/src'), { recursive: true });
|
||||
for (const file of ['util.mjs', 'errors.mjs', 'vocabulary.mjs'])
|
||||
cpSync(path.join(PACKAGES, 'organization/src', file), path.join(repo.root, 'packages/organization/src', file));
|
||||
const dataRoot = path.join(repo.base, 'data');
|
||||
const config = path.join(repo.base, 'config.json');
|
||||
writeFileSync(config, JSON.stringify({ dataRoot }));
|
||||
|
||||
@@ -72,10 +72,10 @@ function checkUser(user, at, ctx, humans, seenSubjects) {
|
||||
requireObject(user, at);
|
||||
rejectUnknownKeys(user, ["id", "home", "subjects"], at);
|
||||
requireId(user.id, `${at}.id`);
|
||||
if (RESERVED_USERS.includes(user.id)) refuse(`${at}.id must not be ${JSON.stringify(user.id)}, a bus principal`);
|
||||
if (humans.has(user.id)) refuse(`${at}.id must not be a business's human (${JSON.stringify(user.id)})`);
|
||||
if (RESERVED_USERS.includes(user.id)) refuse(`${at}.id must not be a bus principal`);
|
||||
if (humans.has(user.id)) refuse(`${at}.id must not be a business's human`);
|
||||
if (user.home !== LOCAL_HOME && !ctx.peers.has(user.home)) {
|
||||
refuse(`${at}.home must be a configured peer or ${JSON.stringify(LOCAL_HOME)} (got ${JSON.stringify(user.home)})`);
|
||||
refuse(`${at}.home must be a configured peer or ${JSON.stringify(LOCAL_HOME)}`);
|
||||
}
|
||||
const subjects = requireArray(user.subjects, `${at}.subjects`);
|
||||
if (subjects.length === 0) refuse(`${at}.subjects must not be empty`);
|
||||
@@ -84,7 +84,7 @@ function checkUser(user, at, ctx, humans, seenSubjects) {
|
||||
requireObject(s, sat);
|
||||
rejectUnknownKeys(s, ["provider", "sub"], sat);
|
||||
if (typeof s.provider !== "string" || !ctx.providers.has(s.provider)) {
|
||||
refuse(`${sat}.provider must be a configured provider (got ${JSON.stringify(s.provider)})`);
|
||||
refuse(`${sat}.provider must be a configured provider`);
|
||||
}
|
||||
requireText(s.sub, `${sat}.sub`);
|
||||
const key = subjectKey(s.provider, s.sub);
|
||||
@@ -105,37 +105,37 @@ function checkScope(value, where, check) {
|
||||
const seen = new Set();
|
||||
list.forEach((entry, i) => {
|
||||
check(entry, `${where}[${i}]`);
|
||||
if (seen.has(entry)) refuse(`duplicate entry in ${where}: ${JSON.stringify(entry)}`);
|
||||
if (seen.has(entry)) refuse(`duplicate entry in ${where}`);
|
||||
seen.add(entry);
|
||||
});
|
||||
}
|
||||
|
||||
function checkExpires(value, where) {
|
||||
if (typeof value !== "string" || !ISO_TIME.test(value) || Number.isNaN(Date.parse(value))) {
|
||||
refuse(`${where} must be an ISO time such as 2026-12-31T00:00:00Z (got ${JSON.stringify(value)})`);
|
||||
refuse(`${where} must be an ISO time such as 2026-12-31T00:00:00Z`);
|
||||
}
|
||||
const iso = new Date(value).toISOString();
|
||||
if (iso.slice(0, 19) !== value.slice(0, 19)) refuse(`${where} is not a real time: ${value}`);
|
||||
if (iso.slice(0, 19) !== value.slice(0, 19)) refuse(`${where} is not a real time`);
|
||||
}
|
||||
|
||||
function checkGrant(grant, at, ctx, users, seenGrants) {
|
||||
requireObject(grant, at);
|
||||
rejectUnknownKeys(grant, ["id", "user", "business", "projects", "instances", "level", "expires"], at);
|
||||
requireId(grant.id, `${at}.id`);
|
||||
if (seenGrants.has(grant.id)) refuse(`${at}.id is a duplicate grant id: ${grant.id}`);
|
||||
if (seenGrants.has(grant.id)) refuse(`${at}.id is a duplicate grant id`);
|
||||
seenGrants.add(grant.id);
|
||||
if (typeof grant.user !== "string" || !users.has(grant.user)) refuse(`${at}.user must name a user in this file (got ${JSON.stringify(grant.user)})`);
|
||||
if (typeof grant.user !== "string" || !users.has(grant.user)) refuse(`${at}.user must name a user in this file`);
|
||||
if (typeof grant.business !== "string" || !ctx.businesses.has(grant.business)) {
|
||||
refuse(`${at}.business must name a declared business (got ${JSON.stringify(grant.business)})`);
|
||||
refuse(`${at}.business must name a declared business`);
|
||||
}
|
||||
const business = ctx.businesses.get(grant.business);
|
||||
checkScope(grant.projects, `${at}.projects`, (p, where) => {
|
||||
if (typeof p !== "string" || !UUID.test(p)) refuse(`${where} must be "*" or a bus project id, a lowercase UUID (got ${JSON.stringify(p)})`);
|
||||
if (typeof p !== "string" || !UUID.test(p)) refuse(`${where} must be "*" or a bus project id, a lowercase UUID`);
|
||||
});
|
||||
checkScope(grant.instances, `${at}.instances`, (x, where) => {
|
||||
if (typeof x !== "string" || !business.instances.has(x)) refuse(`${where} must be "*" or a declared instance of ${grant.business} (got ${JSON.stringify(x)})`);
|
||||
if (typeof x !== "string" || !business.instances.has(x)) refuse(`${where} must be "*" or a declared instance of the grant's business`);
|
||||
});
|
||||
if (!LEVELS.includes(grant.level)) refuse(`${at}.level must be one of ${LEVELS.join(", ")} (got ${JSON.stringify(grant.level)})`);
|
||||
if (!LEVELS.includes(grant.level)) refuse(`${at}.level must be one of ${LEVELS.join(", ")}`);
|
||||
if (grant.expires !== undefined) checkExpires(grant.expires, `${at}.expires`);
|
||||
}
|
||||
|
||||
@@ -147,13 +147,13 @@ export function checkAccess(document, context) {
|
||||
const humans = new Set([...ctx.businesses.values()].map((b) => b.human));
|
||||
if (!isPlainObject(document)) refuse("access file must be a JSON object");
|
||||
rejectUnknownKeys(document, ["accessVersion", "revision", "users", "grants"], "access file");
|
||||
if (document.accessVersion !== 1) refuse(`accessVersion must be 1 (got ${JSON.stringify(document.accessVersion)})`);
|
||||
if (document.accessVersion !== 1) refuse(`accessVersion must be 1`);
|
||||
requirePositiveInt(document.revision, "revision");
|
||||
const users = new Set();
|
||||
const seenSubjects = new Set();
|
||||
requireArray(document.users, "users").forEach((user, i) => {
|
||||
checkUser(user, `users[${i}]`, ctx, humans, seenSubjects);
|
||||
if (users.has(user.id)) refuse(`users[${i}].id is a duplicate user id: ${user.id}`);
|
||||
if (users.has(user.id)) refuse(`users[${i}].id is a duplicate user id`);
|
||||
users.add(user.id);
|
||||
});
|
||||
const seenGrants = new Set();
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
// #1873 Phase A slice A2: the root agent file's writer (design
|
||||
// docs/plans/2026-11-10_agent-levels-design.md sections 6, 7 and 9 item 1).
|
||||
//
|
||||
// One instance-wide file, <configDir>/agents.json. Only the signed-in owner writes it: the caller
|
||||
// passes the actor and the owner principal its own trusted context established (the CLI's local
|
||||
// owner, the Console's owner session), and anything else refuses `human-required`. The write
|
||||
// follows M2's locked store (accounts.mjs): a lock file, a re-read and revision check under it, the
|
||||
// whole new document checked with A1's checkRootAgents, a 0600 temp file, a re-check, then rename
|
||||
// (or link for a new file), a directory sync and one receipt line. Nothing here launches an agent.
|
||||
//
|
||||
// No refusal and no receipt carries a value from the file or the request: a var value can be a
|
||||
// secret typed into the wrong place. Messages name the file (agents.json) and the key, never a
|
||||
// host path.
|
||||
import { constants, lstatSync } from 'node:fs';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { join, parse, resolve } from 'node:path';
|
||||
import { OrgError } from './org-registry.mjs';
|
||||
import { ROOT_FILE, checkProjectAgents, checkRootAgents, loadRootAgents, projectUnion, rootAgentsPath } from './agent-levels.mjs';
|
||||
import { canonicalJson, isPlainObject } from './util.mjs';
|
||||
import { ID_PATTERN } from './vocabulary.mjs';
|
||||
import { REGISTRY } from './vars.mjs';
|
||||
|
||||
export const ROOT_LOCK = 'agents.lock';
|
||||
export const ROOT_RECEIPTS = 'agents-receipts.jsonl';
|
||||
const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
|
||||
// The revision of a root file that does not exist yet: the digest of no bytes.
|
||||
export const ABSENT_REVISION = sha256('');
|
||||
const REVISION = /^[a-f0-9]{64}$/;
|
||||
const PRINCIPAL = ['provider', 'iss', 'sub'];
|
||||
const fail = (code, message = code) => { throw new OrgError(code, message); };
|
||||
|
||||
// The only actor that writes: the owner principal the caller's trusted context names. The tester
|
||||
// identity never counts as the owner, whatever a context says.
|
||||
export function requireRootOwner(actor, owner) {
|
||||
const ok = p => isPlainObject(p) && PRINCIPAL.every(k => typeof p[k] === 'string' && p[k] !== '');
|
||||
if (!ok(actor) || !ok(owner) || PRINCIPAL.some(k => actor[k] !== owner[k]) || actor.sub === 'tester' || actor.provider === 'tester')
|
||||
fail('human-required', 'only the signed-in owner writes the root agent file');
|
||||
}
|
||||
|
||||
// Every ancestor a real directory that only root or this uid can change: owned by one of them, and
|
||||
// group or other writable only with the sticky bit (as /tmp is), so no other user can rename or
|
||||
// replace configDir between these checks and the path-based lock, temp file and rename. configDir
|
||||
// itself is this uid's and not group or other writable.
|
||||
function assertConfigDir(configDir, uid) {
|
||||
if (typeof configDir !== 'string' || resolve(configDir) !== configDir) fail('agents-unsafe', 'config directory must be an absolute normalized path');
|
||||
let current = parse(configDir).root;
|
||||
const trusted = st => (st.uid === 0 || st.uid === uid) && (!(st.mode & 0o022) || (st.mode & 0o1000));
|
||||
try { if (!trusted(lstatSync(current))) fail('agents-unsafe', `a config directory ancestor must belong to root or uid ${uid} and not be writable by others without the sticky bit`); }
|
||||
catch (error) { if (error instanceof OrgError) throw error; fail('agents-unsafe', 'config directory is missing'); }
|
||||
for (const part of configDir.slice(current.length).split('/').filter(Boolean)) {
|
||||
current = join(current, part);
|
||||
let st;
|
||||
try { st = lstatSync(current); } catch { fail('agents-unsafe', 'config directory is missing'); }
|
||||
if (!st.isDirectory() || st.isSymbolicLink()) fail('agents-unsafe', 'config path contains a symbolic link or non-directory');
|
||||
if (current !== configDir && !trusted(st)) fail('agents-unsafe', `a config directory ancestor must belong to root or uid ${uid} and not be writable by others without the sticky bit`);
|
||||
}
|
||||
const st = lstatSync(configDir);
|
||||
if (st.uid !== uid) fail('agents-unsafe', `config directory must belong to uid ${uid}`);
|
||||
if (st.mode & 0o022) fail('agents-unsafe', 'config directory must not be writable by group or other');
|
||||
}
|
||||
|
||||
// The reader's descriptor checks (loadRootAgents), plus one link: a regular file, no symbolic link,
|
||||
// this uid, no group or other write. Missing is an empty root level at ABSENT_REVISION.
|
||||
async function readCurrent(configDir, uid, statFile = fd => fd.stat()) {
|
||||
const file = rootAgentsPath(configDir);
|
||||
let fd;
|
||||
try { fd = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
|
||||
catch (error) {
|
||||
if (error.code === 'ENOENT') return { exists: false, revision: ABSENT_REVISION, document: { agentsVersion: 1, agents: {} } };
|
||||
fail('agents-unsafe', `root agent file must be a regular file, not a symbolic link (${ROOT_FILE})`);
|
||||
}
|
||||
let bytes;
|
||||
try {
|
||||
const st = await statFile(fd);
|
||||
if (!st.isFile()) fail('agents-unsafe', `root agent file must be a regular file, not a symbolic link (${ROOT_FILE})`);
|
||||
if (st.uid !== uid) fail('agents-unsafe', `root agent file must belong to uid ${uid} (${ROOT_FILE})`);
|
||||
if (st.mode & 0o022) fail('agents-unsafe', `root agent file must not be writable by group or other (${ROOT_FILE})`);
|
||||
if (st.nlink !== 1) fail('agents-unsafe', `root agent file must have exactly one link (${ROOT_FILE})`);
|
||||
bytes = await fd.readFile();
|
||||
} finally { await fd.close(); }
|
||||
let document;
|
||||
// The parser's text can quote the file's bytes: say only that it is not JSON.
|
||||
try { document = JSON.parse(bytes.toString('utf8')); } catch { fail('agents-invalid', `root agent file is not valid JSON (${ROOT_FILE})`); }
|
||||
return { exists: true, revision: sha256(bytes), document };
|
||||
}
|
||||
|
||||
// The file's document and revision, for a form or a CLI list. A file that is not a root agent
|
||||
// document at all refuses; one with a bad entry reads, so the owner can fix or remove that entry.
|
||||
// statFile is a test seam for the descriptor stat; it grants nothing a caller's uid does not.
|
||||
export async function readRootAgentsFile(configDir, { uid = process.geteuid(), statFile } = {}) {
|
||||
assertConfigDir(configDir, uid);
|
||||
const current = await readCurrent(configDir, uid, statFile);
|
||||
shape(current.document);
|
||||
return current;
|
||||
}
|
||||
|
||||
function shape(document) {
|
||||
if (!isPlainObject(document) || document.agentsVersion !== 1 || !isPlainObject(document.agents))
|
||||
fail('agents-invalid', `root agent file must be {"agentsVersion": 1, "agents": {...}} (${ROOT_FILE})`);
|
||||
}
|
||||
|
||||
// Every string a request put in an entry, as a message could quote it bare or as JSON.
|
||||
// What a refusal must not show. Field names, var names, role ids and org ids may appear; every
|
||||
// var value, every unknown key and any other string that isn't an id is caller text.
|
||||
const FIELDS = new Set(['definition', 'vars', 'excludeOrgs', 'exclude', 'credentials', 'holder', 'tracker', 'identity', 'publish', 'hosts']);
|
||||
function all(value, out) {
|
||||
if (typeof value === 'string') { if (value.length >= 3) out.push(value, JSON.stringify(value)); }
|
||||
else if (Array.isArray(value)) value.forEach(v => all(v, out));
|
||||
else if (isPlainObject(value)) for (const [key, v] of Object.entries(value)) { all(key, out); all(v, out); }
|
||||
return out;
|
||||
}
|
||||
const unlessId = (value, out) => (typeof value === 'string' && ID_PATTERN.test(value) ? out : all(value, out));
|
||||
function strings(entry, out = []) {
|
||||
if (!isPlainObject(entry)) return all(entry, out);
|
||||
for (const [key, value] of Object.entries(entry)) {
|
||||
if (!FIELDS.has(key)) all(key, out);
|
||||
if (key === 'definition') unlessId(value, out);
|
||||
else if (key === 'excludeOrgs' && Array.isArray(value)) value.forEach(v => unlessId(v, out));
|
||||
else if (key === 'exclude' && typeof value === 'boolean') continue;
|
||||
else if (key === 'vars' && isPlainObject(value)) for (const [name, v] of Object.entries(value)) { if (!Object.hasOwn(REGISTRY, name)) all(name, out); all(v, out); }
|
||||
else all(value, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// A refusal's message with its key path and without any value: an id check's "(got ...)" is cut,
|
||||
// and a message that still holds a string from the entry is replaced by one naming the key only.
|
||||
export function withoutValues(message, entry, prefix) {
|
||||
const cut = String(message).replace(/ \(got [\s\S]*\)$/, '');
|
||||
if (!strings(entry).some(text => cut.includes(text))) return cut;
|
||||
const at = new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\.[^\\s:]+)*`).exec(cut)?.[0] ?? prefix;
|
||||
return `${at}: refused (the value is not shown)`;
|
||||
}
|
||||
|
||||
// A1's checks on the whole new document.
|
||||
function validate(document, rolesDir, entry) {
|
||||
try { return checkRootAgents(document, { rolesDir }); }
|
||||
catch (error) {
|
||||
if (error?.name !== 'BusinessError') throw error;
|
||||
fail('agents-invalid', withoutValues(error.message, entry, 'root agents'));
|
||||
}
|
||||
}
|
||||
|
||||
// What changed in one entry, by key name only.
|
||||
function changedKeys(before, after) {
|
||||
if (!before && !after) return [];
|
||||
if (!before) return ['added'];
|
||||
if (!after) return ['removed'];
|
||||
const out = [];
|
||||
if (before.definition !== after.definition) out.push('definition');
|
||||
const bv = isPlainObject(before.vars) ? before.vars : {}, av = isPlainObject(after.vars) ? after.vars : {};
|
||||
for (const key of [...new Set([...Object.keys(bv), ...Object.keys(av)])].sort()) {
|
||||
if (canonicalJson(bv[key]) !== canonicalJson(av[key])) out.push(`vars.${key}`);
|
||||
}
|
||||
if (canonicalJson(before.excludeOrgs ?? []) !== canonicalJson(after.excludeOrgs ?? [])) out.push('excludeOrgs');
|
||||
return out;
|
||||
}
|
||||
|
||||
// One entry changed by a patch: `definition` replaces, `vars` sets each key it names (null removes
|
||||
// that key), `excludeOrgs` replaces the list ([] clears it). Keys the patch leaves out are kept.
|
||||
export function patchRootEntry(old, { definition, vars, excludeOrgs } = {}) {
|
||||
const next = old ? structuredClone(old) : {};
|
||||
if (definition !== undefined) next.definition = definition;
|
||||
if (vars !== undefined) {
|
||||
if (!isPlainObject(vars)) fail('agents-invalid', 'vars must be an object of keys');
|
||||
const merged = isPlainObject(next.vars) ? { ...next.vars } : {};
|
||||
for (const [key, value] of Object.entries(vars)) { if (value === null) delete merged[key]; else merged[key] = value; }
|
||||
if (Object.keys(merged).length) next.vars = merged; else delete next.vars;
|
||||
}
|
||||
if (excludeOrgs !== undefined) { if (Array.isArray(excludeOrgs) && excludeOrgs.length === 0) delete next.excludeOrgs; else next.excludeOrgs = excludeOrgs; }
|
||||
return next;
|
||||
}
|
||||
|
||||
async function syncDirectory(dir) {
|
||||
const fd = await fs.open(dir, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
||||
try { await fd.sync(); } finally { await fd.close(); }
|
||||
}
|
||||
|
||||
// Change one root agent. `entry` is the new entry, null to remove it, or a function of the old
|
||||
// entry (undefined when there is none) returning either. Returns the receipt. An error after the
|
||||
// rename carries committed: true, so a caller never retries a write that landed.
|
||||
export async function changeRootAgent(configDir, { revision, name, entry }, { actor, owner, rolesDir, uid = process.geteuid(), beforeReplace, statFile, syncDirectory: syncDir = syncDirectory } = {}) {
|
||||
requireRootOwner(actor, owner);
|
||||
if (typeof name !== 'string' || !ID_PATTERN.test(name)) fail('agents-invalid', 'agent name must be an id: lowercase letters, digits, dot, dash or underscore, at most 64');
|
||||
if (typeof revision !== 'string' || !REVISION.test(revision)) fail('revision-conflict', 'a root agent change needs the revision it was read at');
|
||||
assertConfigDir(configDir, uid);
|
||||
const lock = join(configDir, ROOT_LOCK);
|
||||
let lockFd;
|
||||
for (let n = 0; n < 200; n++) {
|
||||
try { lockFd = await fs.open(lock, 'wx', 0o600); break; }
|
||||
catch (error) { if (error.code !== 'EEXIST') throw error; await new Promise(r => setTimeout(r, 10)); }
|
||||
}
|
||||
if (!lockFd) fail('agents-locked', 'the root agent file is locked by another write; inspect agents.lock before retrying');
|
||||
const file = rootAgentsPath(configDir);
|
||||
let temp, receiptFd, committed = false;
|
||||
try {
|
||||
assertConfigDir(configDir, uid);
|
||||
const before = await readCurrent(configDir, uid, statFile);
|
||||
if (before.revision !== revision) fail('revision-conflict', 'the root agent file changed since it was read; read it again');
|
||||
shape(before.document);
|
||||
const old = Object.hasOwn(before.document.agents, name) ? before.document.agents[name] : undefined;
|
||||
const value = typeof entry === 'function' ? entry(old === undefined ? undefined : structuredClone(old)) : entry;
|
||||
if (value === null && old === undefined) fail('agent-not-found', `root agents.${name} does not exist`);
|
||||
if (value !== null && !isPlainObject(value)) fail('agents-invalid', `root agents.${name} must be an object`);
|
||||
const next = structuredClone(before.document);
|
||||
if (value === null) delete next.agents[name]; else next.agents[name] = value;
|
||||
const changed = changedKeys(old, value ?? undefined);
|
||||
if (changed.length === 0) fail('no-change', `root agents.${name} is already that`);
|
||||
validate(next, rolesDir, value);
|
||||
const bytes = JSON.stringify(next, null, 2) + '\n';
|
||||
// Check what lands on disk, not only the object built: JSON can drop or reshape a value.
|
||||
validate(JSON.parse(bytes), rolesDir, value);
|
||||
const after = sha256(bytes);
|
||||
const line = {
|
||||
at: new Date().toISOString(), uid: process.geteuid(), actor: 'owner',
|
||||
verb: value === null ? 'root.agent.remove' : 'root.agent.set', agent: name,
|
||||
...(value === null ? {} : { definition: value.definition }),
|
||||
revisionBefore: before.revision, revisionAfter: after, changed,
|
||||
};
|
||||
// Refuse an unsafe receipt destination before publishing the change.
|
||||
receiptFd = await fs.open(join(configDir, ROOT_RECEIPTS), constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
|
||||
const st = await receiptFd.stat();
|
||||
if (!st.isFile() || st.uid !== uid || (st.mode & 0o022) || st.nlink !== 1) fail('agents-unsafe', `unsafe root agent receipt file (${ROOT_RECEIPTS})`);
|
||||
temp = join(configDir, `.agents-${randomBytes(12).toString('hex')}`);
|
||||
const fd = await fs.open(temp, 'wx', 0o600);
|
||||
try { await fd.writeFile(bytes); await fd.sync(); } finally { await fd.close(); }
|
||||
await beforeReplace?.();
|
||||
// A writer that ignores the lock still loses: the bytes must be the ones read.
|
||||
const again = await readCurrent(configDir, uid, statFile);
|
||||
if (again.exists !== before.exists || again.revision !== before.revision) fail('revision-conflict', 'the root agent file changed before the write; read it again');
|
||||
// A new file is published with link, which fails rather than replace a file made meanwhile.
|
||||
if (before.exists) await fs.rename(temp, file);
|
||||
else await fs.link(temp, file);
|
||||
committed = true;
|
||||
if (!before.exists) await fs.unlink(temp);
|
||||
await syncDir(configDir);
|
||||
await receiptFd.writeFile(JSON.stringify(line) + '\n'); await receiptFd.sync();
|
||||
return line;
|
||||
} catch (error) { if (committed && error instanceof Error) error.committed = true; throw error; }
|
||||
finally {
|
||||
if (temp) await fs.rm(temp, { force: true });
|
||||
if (receiptFd) await receiptFd.close();
|
||||
await lockFd.close(); await fs.unlink(lock);
|
||||
}
|
||||
}
|
||||
|
||||
// The entries as a form or a list shows them: names, definition, the model, the other var keys and
|
||||
// the opted-out organizations. No var value other than the model.
|
||||
export function rootAgentSummary(document) {
|
||||
return Object.entries(isPlainObject(document?.agents) ? document.agents : {}).map(([name, entry]) => {
|
||||
const vars = isPlainObject(entry?.vars) ? entry.vars : {};
|
||||
return {
|
||||
name, definition: typeof entry?.definition === 'string' ? entry.definition : null,
|
||||
model: typeof vars.model === 'string' ? vars.model : null,
|
||||
varKeys: Object.keys(vars).filter(k => k !== 'model').sort(),
|
||||
excludeOrgs: Array.isArray(entry?.excludeOrgs) ? entry.excludeOrgs.filter(x => typeof x === 'string') : [],
|
||||
};
|
||||
}).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
// One row per entry: its form and what it names, never a var value.
|
||||
export function projectAgentSummary(agents) {
|
||||
if (!isPlainObject(agents)) return [];
|
||||
return Object.keys(agents).sort().map(name => {
|
||||
const entry = isPlainObject(agents[name]) ? agents[name] : {};
|
||||
if (entry.exclude === true) return { name, form: 'exclude' };
|
||||
if (entry.definition !== undefined) return { name, form: 'define', definition: typeof entry.definition === 'string' ? entry.definition : null, varKeys: Object.keys(isPlainObject(entry.vars) ? entry.vars : {}).sort() };
|
||||
return { name, form: 'narrow', varKeys: Object.keys(isPlainObject(entry.vars) ? entry.vars : {}).sort() };
|
||||
});
|
||||
}
|
||||
|
||||
// The project-agent verb's guard (design section 7, the projects fence's one exception for agents):
|
||||
// `next` may differ from `old` only in projects.<project>.agents.<name>, the project must exist and
|
||||
// not be a library, and the project's entries and its union must pass A1's checks. Returns
|
||||
// [code, message] or null. `root` is the root level as loadRootAgents reads it.
|
||||
export function projectAgentProblem(old, next, { orgId, project, name, entry, rolesDir, root }) {
|
||||
const target = old?.projects?.[project];
|
||||
if (!isPlainObject(target)) return ['project-not-found', `projects.${project} does not exist`];
|
||||
if (target.kind === 'library') return ['project-agents-library', `projects.${project} is a library project; it takes no agents`];
|
||||
const without = doc => {
|
||||
const copy = structuredClone(doc);
|
||||
const p = copy?.projects?.[project];
|
||||
if (isPlainObject(p) && isPlainObject(p.agents)) {
|
||||
delete p.agents[name];
|
||||
if (Object.keys(p.agents).length === 0) delete p.agents;
|
||||
}
|
||||
return canonicalJson(copy);
|
||||
};
|
||||
if (without(old) !== without(next)) return ['organization-projects-protected', `only projects.${project}.agents.${name} may change`];
|
||||
const after = next.projects[project].agents?.[name];
|
||||
if (canonicalJson(after ?? null) !== canonicalJson(entry ?? null)) return ['organization-projects-protected', `projects.${project}.agents.${name} is not the entry asked for`];
|
||||
if (canonicalJson(after ?? null) === canonicalJson(target.agents?.[name] ?? null)) return ['no-change', `projects.${project}.agents.${name} is already that`];
|
||||
const where = `projects.${project}.agents`;
|
||||
let entries;
|
||||
try { entries = checkProjectAgents(next.projects[project].agents ?? {}, { rolesDir, where }); }
|
||||
catch (error) { if (error?.name !== 'BusinessError') throw error; return ['project-agents-invalid', withoutValues(error.message, entry, where)]; }
|
||||
const org = Object.fromEntries(Object.entries(old.roles ?? {}).map(([n, r]) => [n, { definition: r?.definition, vars: r?.vars ?? {} }]));
|
||||
try { projectUnion({ orgId, root, org, project: entries, projectId: project }); }
|
||||
catch (error) { if (error?.name !== 'BusinessError') throw error; return ['project-agents-invalid', withoutValues(error.message, entry, where)]; }
|
||||
return null;
|
||||
}
|
||||
|
||||
// The root level a project union is checked against. A root file that refuses refuses the project
|
||||
// write too: an exclusion or narrowing checked against a root level that did not parse could be wrong.
|
||||
export function rootForProjectCheck(configDir, rolesDir) {
|
||||
try { return loadRootAgents(configDir, { rolesDir }); }
|
||||
catch (error) { if (error?.name !== 'BusinessError') throw error; fail('project-agents-invalid', 'the root agent file refuses; fix it before changing project agents'); }
|
||||
}
|
||||
|
||||
// A project entry's form, for the change log and the receipt.
|
||||
export const entryForm = entry => entry === null ? 'remove' : Object.hasOwn(entry, 'exclude') ? 'exclude' : Object.hasOwn(entry, 'definition') ? 'define' : 'narrow';
|
||||
@@ -18,10 +18,10 @@ export function checkAppearance(value, where) {
|
||||
requireObject(value, where);
|
||||
rejectUnknownKeys(value, ["theme", "mode"], where);
|
||||
if (Object.hasOwn(value, "theme") && (typeof value.theme !== "string" || value.theme.length > 64 || !NAME.test(value.theme))) {
|
||||
refuse(`${where}.theme must be an installed theme's name, matching ${NAME} (got ${JSON.stringify(value.theme)})`);
|
||||
refuse(`${where}.theme must be an installed theme's name, matching ${NAME}`);
|
||||
}
|
||||
if (Object.hasOwn(value, "mode") && !APPEARANCE_MODES.includes(value.mode)) {
|
||||
refuse(`${where}.mode must be one of ${APPEARANCE_MODES.join(", ")} (got ${JSON.stringify(value.mode)})`);
|
||||
refuse(`${where}.mode must be one of ${APPEARANCE_MODES.join(", ")}`);
|
||||
}
|
||||
return Object.freeze({
|
||||
...(Object.hasOwn(value, "theme") ? { theme: value.theme } : {}),
|
||||
|
||||
@@ -67,7 +67,20 @@ export function runConfigCommand(argv, { stdout = process.stdout, stderr = proce
|
||||
maxBuffer: 1024 * 1024,
|
||||
env,
|
||||
});
|
||||
if (proc.status !== 0) fail(3, `system config problem (mosaic-config exit ${proc.status}): ${proc.stderr.trim()}`);
|
||||
// Render known key paths with fixed constraints; never relay child stderr.
|
||||
if (proc.status !== 0) {
|
||||
const problem = proc.stderr.trim();
|
||||
const field = problem.startsWith('mosaic-config: execution "dataRoot"') ? "dataRoot"
|
||||
: /^mosaic-config: (?:unsupported )?"?((?:configVersion|environment|dataRoot|execution|console|federation|compactionReadBytes|compactionStreams)(?:\.[A-Za-z][A-Za-z0-9]*|\[\d+\])*)"?(?=[: ])/u.exec(problem)?.[1];
|
||||
const name = "a 1-200 character name starting with a letter or digit and containing only letters, digits, . _ : / -";
|
||||
const expected = { configVersion: "1", environment: "development or production", dataRoot: "a canonical absolute instance data path",
|
||||
"execution.backend": "docker", "execution.adapter": "pi or mock", "execution.provider": name, "execution.model": name };
|
||||
const detail = problem.startsWith("mosaic-config: configuration is not valid JSON (")
|
||||
? "configuration is not valid JSON; expected readable JSON: "
|
||||
: field ? `${field}: expected ${expected[field] ?? "a value satisfying the system configuration schema"}: `
|
||||
: "expected valid system configuration: ";
|
||||
fail(3, `system config problem (mosaic-config exit ${proc.status}): ${detail}${env.MOSAIC_CONFIG || join(env.HOME || "~", ".config", "mosaic-dev", "config.json")}${proc.error?.code ? ` (${proc.error.code})` : ""}`);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(proc.stdout);
|
||||
} catch {
|
||||
@@ -106,7 +119,7 @@ export function runConfigCommand(argv, { stdout = process.stdout, stderr = proce
|
||||
continue;
|
||||
}
|
||||
const project = loadProject(entry.root);
|
||||
if (project.id !== id) fail(2, `project file ${file} has id ${project.id}, but business ${organization.id} declares it as ${id}`);
|
||||
if (project.id !== id) fail(2, `project file ${file} has id that does not match the declared project key`);
|
||||
for (const instance of Object.keys(organization.roles)) resolveInstance({ system, business: organization, project, instance });
|
||||
projects[id] = "valid";
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { checkReplyLimits } from "../../library/src/limits.mjs";
|
||||
import { ADDITION_VERB, additionProblem } from "./project-bootstrap.mjs";
|
||||
import { remoteProblem, remoteTarget } from "./project-remotes.mjs";
|
||||
import { withRegistryWriter } from "./org-registry.mjs";
|
||||
import { entryForm, projectAgentProblem, rootForProjectCheck } from "./agent-levels-write.mjs";
|
||||
|
||||
export class OrganizationError extends Error {
|
||||
constructor(code, message = code) {
|
||||
@@ -272,7 +273,9 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
// The one commit path. `guard(old, document)` is what a caller may do to `projects`: write() keeps
|
||||
// them whole (C2-04 M9) and addProject() allows one exact addition. `document` may be a function of
|
||||
// the old document, for a writer that builds what it publishes from what it read under the lock.
|
||||
function commit(id, document, { revision, actor, detail, context, beforeWrite = null, guard }) {
|
||||
// #1873 A2: `pathsOnly` logs each change's path and presence without its values, for a verb whose
|
||||
// values can hold a var a person typed (a secret pasted in the wrong place must not reach evidence).
|
||||
function commit(id, document, { revision, actor, detail, context, beforeWrite = null, guard, pathsOnly = false }) {
|
||||
requireId(actor, "authenticated actor");
|
||||
requireId(id, "organization id");
|
||||
let revoked = false;
|
||||
@@ -320,7 +323,8 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
syncDir(tx);
|
||||
const event = {
|
||||
transaction, actor, ...(context === undefined ? {} : { context }), business: id, before, after,
|
||||
changes: changes(old, written), ...(detail === undefined ? {} : { detail }), at: new Date().toISOString(),
|
||||
changes: pathsOnly ? changes(old, written).map(({ path, oldPresent, newPresent }) => ({ path, oldPresent, newPresent })) : changes(old, written),
|
||||
...(detail === undefined ? {} : { detail }), at: new Date().toISOString(),
|
||||
};
|
||||
log(id, { ...event, state: "prepared" });
|
||||
syncDir(home);
|
||||
@@ -382,6 +386,36 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
}, { revision, actor, guard, detail: { verb: "project.remote.set", project } });
|
||||
}
|
||||
|
||||
// #1873 A2 (design section 7): the only writer of `projects.<p>.agents`, one entry per call. `entry`
|
||||
// is a define ({definition, vars?}), a narrow ({vars}), an exclude ({exclude: true}) or null to
|
||||
// remove the entry. The human owner only: every agent actor, the tester included, refuses
|
||||
// human-required, and so does any actor that is not the organization's human. The guard checks the
|
||||
// candidate and the serialized bytes: nothing but that entry changes, the project exists and is not
|
||||
// a library, and the project's entries and union pass A1's checks against the current root level.
|
||||
// The change log names paths only; the detail names the project, the agent and the form.
|
||||
function setProjectAgent(id, { revision, project, name, entry }, actor) {
|
||||
requireId(id, "organization id");
|
||||
requireId(project, "project id");
|
||||
requireId(name, "agent name");
|
||||
if (entry !== null && (entry === undefined || typeof entry !== "object" || Array.isArray(entry))) fail("project-agents-invalid", "an entry is an object, or null to remove it");
|
||||
const root = rootForProjectCheck(dir, rolesDir);
|
||||
const guard = (old, document) => {
|
||||
const agents = Object.entries(old.roles ?? {}).flatMap(([n, role]) => [n, `${role?.definition}.${n}`]);
|
||||
if (actor !== old.human || actor === "tester" || agents.includes(actor)) fail("human-required");
|
||||
if (entry === null && old.projects?.[project]?.agents?.[name] === undefined) fail("agent-not-found", `projects.${project}.agents.${name} does not exist`);
|
||||
const problem = projectAgentProblem(old, document, { orgId: id, project, name, entry, rolesDir, root });
|
||||
if (problem) fail(...problem);
|
||||
};
|
||||
return commit(id, old => {
|
||||
const target = old.projects?.[project];
|
||||
if (target === null || typeof target !== "object" || Array.isArray(target)) return old;
|
||||
const agents = { ...(target.agents ?? {}) };
|
||||
if (entry === null) delete agents[name]; else agents[name] = entry;
|
||||
const { agents: _drop, ...rest } = target;
|
||||
return { ...old, projects: { ...old.projects, [project]: Object.keys(agents).length ? { ...rest, agents } : rest } };
|
||||
}, { revision, actor, guard, pathsOnly: true, detail: { verb: "project.agents.set", project, agent: name, form: entryForm(entry) } });
|
||||
}
|
||||
|
||||
// Runs `fn({ revision, document })` under the writer lock, after recovery, and nothing else may
|
||||
// write this business meanwhile. `fn` must be synchronous: the lock is a directory held for its
|
||||
// duration, and an awaited call would leave a half-checked world to whoever finds the lock gone.
|
||||
@@ -449,5 +483,5 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
});
|
||||
}
|
||||
|
||||
return { logFile, read, readRaw, write, addProject, setProjectRemote, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
|
||||
return { logFile, read, readRaw, write, addProject, setProjectRemote, setProjectAgent, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export function validateProjectDocument(document, file) {
|
||||
}
|
||||
|
||||
export function loadProject(root) {
|
||||
if (typeof root !== "string" || !isAbsolute(root) || normalize(root) !== root) refuse(`project root must be a normalized absolute path (got ${JSON.stringify(root)})`);
|
||||
if (typeof root !== "string" || !isAbsolute(root) || normalize(root) !== root) refuse(`project root must be a normalized absolute path`);
|
||||
const file = projectFilePath(root);
|
||||
return validateProjectDocument(readJsonFile(file, "project file"), file);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Caller-only descriptions, shared by launch and live bus reads. Inputs are validated policy,
|
||||
// never credential references, vars, call arguments or file contents.
|
||||
import { GATED_ONLY } from './vocabulary.mjs';
|
||||
|
||||
const DOMAINS = Object.freeze({ 'git.merge.protected': 'technical', 'policy.change': 'delivery' });
|
||||
|
||||
// TI extension point for AU: today's fixed levels, without changing any routing.
|
||||
export function decisionAutonomy(decisionClass) {
|
||||
return ['cross-role', 'merge', 'policy'].includes(decisionClass) ? 'out of the loop' : 'in the loop';
|
||||
}
|
||||
|
||||
export function ownRails({ instance, authority, network, credentials = [], messaging = null, arbiters = {}, launcher = null }) {
|
||||
const actions = [];
|
||||
for (const [kind, held] of Object.entries(authority ?? {})) {
|
||||
for (const action of held) {
|
||||
const chiefOnly = ['role.hire', 'role.fire'].includes(action) && launcher !== instance;
|
||||
const hard = GATED_ONLY.includes(action) || (action === 'role.launch' && launcher !== instance);
|
||||
const domains = DOMAINS[action] ? [DOMAINS[action]] : ['delivery', 'technical'];
|
||||
const routes = chiefOnly ? [{ to: 'unavailable' }] : hard ? [{ to: 'human' }] : kind === 'withinRole' ? [{ to: 'alone' }]
|
||||
: domains.map(domain => ({ domain, to: arbiters[domain] === instance ? 'human' : arbiters[domain] ?? 'unconfigured' }));
|
||||
actions.push({ action, routes });
|
||||
}
|
||||
}
|
||||
const resolve = [...(authority?.withinRole ?? []), ...(authority?.crossRole ?? [])]
|
||||
.filter(action => action.startsWith('decision.resolve.')).map(right => {
|
||||
const domain = right.slice('decision.resolve.'.length);
|
||||
const classes = domain === 'technical' ? ['cross-role', 'merge'] : domain === 'delivery' ? ['cross-role', 'policy'] : [];
|
||||
return { right, classes: classes.map(name => ({ name, level: decisionAutonomy(name) })) };
|
||||
});
|
||||
return {
|
||||
actions, resolve, network: network ?? 'unknown',
|
||||
credentials: credentials.map(({ service, scopes }) => ({ service, ...(scopes === undefined ? {} : { scopes: structuredClone(scopes) }) })),
|
||||
messaging: messaging ? { sendTo: [...messaging.sendTo], receiveFrom: [...messaging.receiveFrom] } : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function railsText(rails) {
|
||||
const lines = ['## Your rails', '', 'These are your resolved rails. Call my_rails for the current rails after a narrowing.'];
|
||||
for (const { action, routes } of rails.actions) {
|
||||
const route = routes.map(({ domain, to }) => `${domain ? `${domain}${domain === 'delivery' && routes.length > 1 ? ' (default)' : ''}: ` : ''}${to === 'human' ? 'a person' : to === 'alone' ? 'alone' : to === 'unavailable' ? 'unavailable here; only the configured chief can hire or fire' : to === 'unconfigured' ? 'unconfigured; report missing arbiter' : `arbiter instance ${JSON.stringify(to)}`}`).join('; ');
|
||||
lines.push(`- ${action}: ${route}.`);
|
||||
}
|
||||
if (!rails.actions.length) lines.push('- Held actions: none.');
|
||||
for (const { right, classes } of rails.resolve) lines.push(`- ${right} levels: ${classes.map(c => `${c.name}: ${c.level}`).join('; ') || 'unconfigured'}.`);
|
||||
lines.push('- A resolve right must be held withinRole, and the decision must route to you. PRD approval also follows the request mode and author checks.');
|
||||
lines.push('- Unheld actions are gated. Hard waits always go to a person; raise with blocking false when there is no task. A current spec citation can route other gated work to its arbiter.');
|
||||
lines.push('- Revoking a protected instance is a hard wait; ending it follows the chief/person protection checks. Messaging also checks the recipient\'s policy.');
|
||||
lines.push(`- Network: ${rails.network}.`);
|
||||
for (const credential of rails.credentials) lines.push(`- Credential service ${credential.service}; scopes: ${credential.scopes === undefined ? 'none declared' : JSON.stringify(credential.scopes)}.`);
|
||||
if (!rails.credentials.length) lines.push('- Credential services: none.');
|
||||
lines.push(`- Messaging sendTo: ${rails.messaging ? rails.messaging.sendTo.join(', ') || 'none' : 'unrestricted by your definition'}.`);
|
||||
lines.push(`- Messaging receiveFrom: ${rails.messaging ? rails.messaging.receiveFrom.join(', ') || 'none' : 'unrestricted by your definition'}.`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
// the record a launcher or the broker uses. Plain values: the most specific
|
||||
// layer wins. Limits: every layer narrows, nothing widens.
|
||||
|
||||
import { ownRails } from "./rails.mjs";
|
||||
import { refuse } from "./errors.mjs";
|
||||
import { ACTIONS, NETWORKS, isHostKind } from "./vocabulary.mjs";
|
||||
import { checkVars, mergeVars } from "./vars.mjs";
|
||||
@@ -89,6 +90,7 @@ export function resolveInstance({ system, business, project = null, instance })
|
||||
limits: { tools, network, authority: { withinRole, crossRole } },
|
||||
// Row 210 (#1707, design D5): a host key is used only by the bus child, never by the session,
|
||||
// so it stays out of the session's credentials and the harness's needs.
|
||||
rails: ownRails({ instance, authority: { withinRole, crossRole }, network, credentials: definition.credentials, messaging: definition.messaging, arbiters: business.arbiters, launcher: business.launch?.by }),
|
||||
credentials: Object.fromEntries(Object.entries(entry.credentials).filter(([kind]) => !isHostKind(kind))),
|
||||
tracker: entry.tracker,
|
||||
launch,
|
||||
|
||||
@@ -24,7 +24,7 @@ const SKILL_NAME = /^[A-Za-z0-9._-]+$/;
|
||||
function checkTools(tools) {
|
||||
if (!Array.isArray(tools) || tools.length === 0) refuse('role "tools" must be a non-empty array of tool names');
|
||||
return requireDistinctList(tools, "role tools", (tool) => {
|
||||
if (!TOOLS.includes(tool)) refuse(`unsupported tool: ${JSON.stringify(tool)} (supported: ${TOOLS.join(", ")})`);
|
||||
if (!TOOLS.includes(tool)) refuse(`unsupported tool (supported: ${TOOLS.join(", ")})`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -39,12 +39,12 @@ function checkAuthority(authority) {
|
||||
const lists = {};
|
||||
for (const key of ["withinRole", "crossRole"]) {
|
||||
lists[key] = requireDistinctList(authority[key], `role authority.${key}`, (action) => {
|
||||
if (!ACTIONS.includes(action)) refuse(`unknown action in authority.${key}: ${JSON.stringify(action)}`);
|
||||
if (GATED_ONLY.includes(action)) refuse(`authority.${key} lists ${action}, which is always gated`);
|
||||
if (!ACTIONS.includes(action)) refuse(`unknown action in authority.${key}; expected an action from the role vocabulary`);
|
||||
if (GATED_ONLY.includes(action)) refuse(`authority.${key} lists an action which is always gated`);
|
||||
});
|
||||
}
|
||||
const both = lists.withinRole.filter((a) => lists.crossRole.includes(a));
|
||||
if (both.length > 0) refuse(`action listed as both withinRole and crossRole: ${both.join(", ")}`);
|
||||
if (both.length > 0) refuse(`action listed as both withinRole and crossRole`);
|
||||
return lists;
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ function checkMessaging(messaging) {
|
||||
function checkSkills(skills) {
|
||||
return requireDistinctList(skills, "role skills", (name) => {
|
||||
if (typeof name !== "string" || !SKILL_NAME.test(name) || name === "." || name === "..") {
|
||||
refuse(`role skill must be a skill directory name matching ${SKILL_NAME} (got ${JSON.stringify(name)})`);
|
||||
refuse(`role skill must be a skill directory name matching ${SKILL_NAME}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -87,13 +87,13 @@ function checkHireable(hireable, file) {
|
||||
const dir = dirname(file);
|
||||
return requireDistinctList(hireable, "role hireable", (name) => {
|
||||
requireId(name, "role hireable entry");
|
||||
if (CREATIVE_DEFINITIONS.includes(name)) refuse(`hireable names ${name}, a creative definition only the human adds`);
|
||||
if (CREATIVE_DEFINITIONS.includes(name)) refuse(`hireable names a creative definition only the human adds`);
|
||||
const path = join(dir, `${name}.json`);
|
||||
if (!existsSync(path)) refuse(`hireable names ${name}, which has no role file`);
|
||||
if (!existsSync(path)) refuse(`hireable names a definition which has no role file`);
|
||||
const named = validateRoleDocument(readJsonFile(path, "role file"), path, { hireable: false });
|
||||
if (named.roleVersion !== 2) refuse(`hireable names ${name}, which is role version 1`);
|
||||
if (named.roleVersion !== 2) refuse(`hireable names a definition which is role version 1`);
|
||||
const outside = unhireableAction(named);
|
||||
if (outside !== null) refuse(`hireable names ${name}, which holds ${outside}`);
|
||||
if (outside !== null) refuse(`hireable names a definition which holds an action outside the hireable ceiling`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -101,12 +101,12 @@ function checkGiteaScopes(scopes) {
|
||||
const list = requireDistinctList(scopes, "gitea scopes", (scope) => {
|
||||
const m = typeof scope === "string" ? /^(read|write):([a-z]+)$/.exec(scope) : null;
|
||||
if (!m || !GITEA_SCOPE_CATEGORIES.includes(m[2])) {
|
||||
refuse(`unsupported gitea scope: ${JSON.stringify(scope)} (expected read:<category> or write:<category>; categories: ${GITEA_SCOPE_CATEGORIES.join(", ")})`);
|
||||
refuse(`unsupported gitea scope (expected read:<category> or write:<category>; categories: ${GITEA_SCOPE_CATEGORIES.join(", ")})`);
|
||||
}
|
||||
}, { nonEmpty: true });
|
||||
const categories = list.map((s) => s.split(":")[1]);
|
||||
const twice = categories.filter((c, i) => categories.indexOf(c) !== i);
|
||||
if (twice.length > 0) refuse(`gitea scopes name ${twice[0]} twice; a token holds one level per category`);
|
||||
if (twice.length > 0) refuse(`gitea scopes name a category twice; a token holds one level per category`);
|
||||
return list;
|
||||
}
|
||||
|
||||
@@ -116,9 +116,9 @@ function checkVikunjaScopes(scopes) {
|
||||
const out = {};
|
||||
for (const [group, verbs] of Object.entries(scopes)) {
|
||||
const allowed = VIKUNJA_GRANTABLE[group];
|
||||
if (!allowed) refuse(`vikunja route group not grantable to a role: ${JSON.stringify(group)}`);
|
||||
if (!allowed) refuse(`vikunja route group not grantable to a role`);
|
||||
out[group] = requireDistinctList(verbs, `vikunja scopes.${group}`, (verb) => {
|
||||
if (!allowed.includes(verb)) refuse(`vikunja verb not grantable to a role: ${group}.${JSON.stringify(verb)}`);
|
||||
if (!allowed.includes(verb)) refuse(`vikunja verb not grantable to a role`);
|
||||
}, { nonEmpty: true });
|
||||
}
|
||||
return out;
|
||||
@@ -135,7 +135,7 @@ function checkCredentials(credentials) {
|
||||
if (!host && !SERVICES.includes(entry.service)) {
|
||||
refuse(`role credentials[${i}].service must be one of: ${[...SERVICES, HOST_SERVICE].join(", ")}`);
|
||||
}
|
||||
if (seen.has(entry.service)) refuse(`role credentials name ${entry.service} twice`);
|
||||
if (seen.has(entry.service)) refuse(`role credentials name a service twice`);
|
||||
seen.add(entry.service);
|
||||
if (host) return { service: HOST_SERVICE };
|
||||
const scopes = entry.service === "gitea" ? checkGiteaScopes(entry.scopes) : checkVikunjaScopes(entry.scopes);
|
||||
@@ -145,7 +145,7 @@ function checkCredentials(credentials) {
|
||||
|
||||
function checkContract(contract, file) {
|
||||
if (typeof contract !== "string" || !CONTRACT_NAME.test(contract)) {
|
||||
refuse(`role "contract" must be a Markdown file name in the role's directory, matching ${CONTRACT_NAME} (got ${JSON.stringify(contract)})`);
|
||||
refuse(`role "contract" must be a Markdown file name in the role's directory, matching ${CONTRACT_NAME}`);
|
||||
}
|
||||
const path = join(dirname(file), contract);
|
||||
let stat;
|
||||
@@ -167,12 +167,12 @@ function checkContract(contract, file) {
|
||||
// docs: its authority is withinRole CREATIVE_ACTIONS at most, and its one credential is gitea.
|
||||
function checkCreative(role) {
|
||||
const wider = role.tools.filter((tool) => !CREATIVE_TOOLS.includes(tool));
|
||||
if (wider.length > 0) refuse(`creative role ${role.name} may not name tools ${wider.join(", ")} (allowed: ${CREATIVE_TOOLS.join(", ")})`);
|
||||
if (wider.length > 0) refuse(`creative role ${role.name} may not name tools outside the allowed set (allowed: ${CREATIVE_TOOLS.join(", ")})`);
|
||||
if (role.authority.crossRole.length > 0 || role.authority.withinRole.some((a) => !CREATIVE_ACTIONS.includes(a))) {
|
||||
refuse(`creative role ${role.name} may hold only withinRole ${CREATIVE_ACTIONS.join(" and ")}`);
|
||||
}
|
||||
const services = role.credentials.map((c) => c.service).filter((service) => service !== CREATIVE_SERVICE);
|
||||
if (services.length > 0) refuse(`creative role ${role.name} may hold only the ${CREATIVE_SERVICE} credential (got ${services.join(", ")})`);
|
||||
if (services.length > 0) refuse(`creative role ${role.name} may hold only the ${CREATIVE_SERVICE} credential`);
|
||||
if (role.messaging === null) refuse(`creative role ${role.name} requires "messaging"`);
|
||||
for (const key of ["sendTo", "receiveFrom"]) {
|
||||
if (role.messaging[key].some((entry) => entry !== "human")) {
|
||||
@@ -193,7 +193,7 @@ export function validateRoleDocument(document, file, options = {}) {
|
||||
rejectUnknownKeys(document, document.roleVersion === 1 ? V1_KEYS : V2_KEYS, "role");
|
||||
requireId(document.name, "role name");
|
||||
const base = basename(file).replace(/\.json$/, "");
|
||||
if (document.name !== base) refuse(`role "name" (${document.name}) must match its filename (${base}.json)`);
|
||||
if (document.name !== base) refuse(`role "name" must match its filename (${base}.json)`);
|
||||
const tools = checkTools(document.tools);
|
||||
if (document.roleVersion === 1) {
|
||||
if (CREATIVE_DEFINITIONS.includes(document.name)) refuse(`creative role ${document.name} must be version 2`);
|
||||
|
||||
@@ -20,7 +20,7 @@ export function rejectUnknownKeys(object, allowed, where) {
|
||||
|
||||
export function requireId(value, where) {
|
||||
if (typeof value !== "string" || !ID_PATTERN.test(value)) {
|
||||
refuse(`${where} must match ${ID_PATTERN} (got ${JSON.stringify(value)})`);
|
||||
refuse(`${where} must match ${ID_PATTERN}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -33,7 +33,7 @@ export function requireString(value, where, { max = 200 } = {}) {
|
||||
}
|
||||
|
||||
export function requirePositiveInt(value, where) {
|
||||
if (!Number.isSafeInteger(value) || value < 1) refuse(`${where} must be a positive integer (got ${JSON.stringify(value)})`);
|
||||
if (!Number.isSafeInteger(value) || value < 1) refuse(`${where} must be a positive integer`);
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export function requireDistinctList(value, where, check, { nonEmpty = false } =
|
||||
const seen = new Set();
|
||||
for (const item of value) {
|
||||
check(item);
|
||||
if (seen.has(item)) refuse(`duplicate entry in ${where}: ${JSON.stringify(item)}`);
|
||||
if (seen.has(item)) refuse(`duplicate entry in ${where}`);
|
||||
seen.add(item);
|
||||
}
|
||||
return [...seen];
|
||||
@@ -52,9 +52,9 @@ export function requireDistinctList(value, where, check, { nonEmpty = false } =
|
||||
|
||||
// Calendar date YYYY-MM-DD that exists (2026-02-30 refuses).
|
||||
export function requireDate(value, where) {
|
||||
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) refuse(`${where} must be a date YYYY-MM-DD (got ${JSON.stringify(value)})`);
|
||||
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) refuse(`${where} must be a date YYYY-MM-DD`);
|
||||
const d = new Date(`${value}T00:00:00Z`);
|
||||
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== value) refuse(`${where} is not a real date: ${value}`);
|
||||
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== value) refuse(`${where} is not a real date`);
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
@@ -295,7 +295,7 @@ export const GRANT_ALL = "*";
|
||||
// and a viewer grant never covers it. An expired grant covers nothing.
|
||||
export function covers(grant, { business, project, instance }, now, { level } = {}) {
|
||||
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError("covers needs a valid Date for now");
|
||||
if (!LEVELS.includes(level)) throw new TypeError(`covers needs a level, one of ${LEVELS.join(", ")} (got ${JSON.stringify(level)})`);
|
||||
if (!LEVELS.includes(level)) throw new TypeError(`covers needs a level, one of ${LEVELS.join(", ")}`);
|
||||
if (grant.expires !== undefined && now.getTime() >= Date.parse(grant.expires)) return false;
|
||||
if (grant.business !== business) return false;
|
||||
if (!(grant.projects[0] === GRANT_ALL || grant.projects.includes(project))) return false;
|
||||
|
||||
@@ -104,19 +104,19 @@ test("users: unknown keys and the id rule", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].role = "x"; }), context()), /unsupported users\[1\] key: "role"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0] = "bean"; }), context()), /users\[0\] must be a JSON object/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "Bean"; }), context()), /users\[0\]\.id must match/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "bean"; }), context()), /users\[1\]\.id is a duplicate user id: bean/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "bean"; }), context()), /users\[1\]\.id is a duplicate user id/);
|
||||
});
|
||||
|
||||
test("users: human, tester and any business's human are refused", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "human"; d.grants = []; }), context()), /users\[0\]\.id must not be "human"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "tester"; d.grants = []; }), context()), /users\[1\]\.id must not be "tester"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "melanie"; d.grants = []; }), context()), /users\[0\]\.id must not be a business's human \("melanie"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "human"; d.grants = []; }), context()), /users\[0\]\.id must not be a bus principal/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "tester"; d.grants = []; }), context()), /users\[1\]\.id must not be a bus principal/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "melanie"; d.grants = []; }), context()), /users\[0\]\.id must not be a business's human/);
|
||||
// The human of a business the grant doesn't name is refused too.
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "jason"; d.grants = []; }), context()), /users\[1\]\.id must not be a business's human \("jason"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "jason"; d.grants = []; }), context()), /users\[1\]\.id must not be a business's human/);
|
||||
});
|
||||
|
||||
test("users: home is a given peer or local", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].home = "elsewhere"; }), context()), /users\[0\]\.home must be a configured peer or "local" \(got "elsewhere"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].home = "elsewhere"; }), context()), /users\[0\]\.home must be a configured peer or "local"/);
|
||||
refuses(() => checkAccess(mutated((d) => { delete d.users[1].home; }), context()), /users\[1\]\.home must be a configured peer/);
|
||||
refuses(() => checkAccess(doc(), context({ peers: [] })), /users\[0\]\.home must be a configured peer/);
|
||||
});
|
||||
@@ -125,7 +125,7 @@ test("users: subjects, provider, sub, uniqueness and owner subjects", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects = []; }), context()), /users\[0\]\.subjects must not be empty/);
|
||||
refuses(() => checkAccess(mutated((d) => { delete d.users[0].subjects; }), context()), /users\[0\]\.subjects must be an array/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].extra = 1; }), context()), /unsupported users\[0\]\.subjects\[0\] key: "extra"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].subjects[0].provider = "google"; }), context()), /users\[1\]\.subjects\[0\]\.provider must be a configured provider \(got "google"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].subjects[0].provider = "google"; }), context()), /users\[1\]\.subjects\[0\]\.provider must be a configured provider/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].sub = ""; }), context()), /users\[0\]\.subjects\[0\]\.sub must be a non-empty string/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].sub = 7; }), context()), /users\[0\]\.subjects\[0\]\.sub must be a non-empty string/);
|
||||
// A pair twice in one user, and across two users.
|
||||
@@ -140,15 +140,15 @@ test("users: subjects, provider, sub, uniqueness and owner subjects", () => {
|
||||
|
||||
test("grants: unknown keys, id, user and business", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].role = "x"; }), context()), /unsupported grants\[0\] key: "role"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].id = "g-1"; }), context()), /grants\[1\]\.id is a duplicate grant id: g-1/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].id = "g-1"; }), context()), /grants\[1\]\.id is a duplicate grant id/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].id = "G 1"; }), context()), /grants\[0\]\.id must match/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].user = "nobody"; }), context()), /grants\[0\]\.user must name a user in this file \(got "nobody"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].business = "other"; }), context()), /grants\[1\]\.business must name a declared business \(got "other"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].user = "nobody"; }), context()), /grants\[0\]\.user must name a user in this file/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].business = "other"; }), context()), /grants\[1\]\.business must name a declared business/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].business = "constructor"; }), context()), /grants\[1\]\.business must name a declared business/);
|
||||
});
|
||||
|
||||
test("grants: projects is [\"*\"] or distinct lowercase UUIDs", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["stack"]; }), context()), /grants\[1\]\.projects\[0\] must be "\*" or a bus project id, a lowercase UUID \(got "stack"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["stack"]; }), context()), /grants\[1\]\.projects\[0\] must be "\*" or a bus project id, a lowercase UUID/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1.toUpperCase()]; }), context()), /grants\[1\]\.projects\[0\] must be "\*" or a bus project id/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1, 7]; }), context()), /grants\[1\]\.projects\[1\] must be "\*" or a bus project id/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["*", P1]; }), context()), /grants\[1\]\.projects must be \["\*"\] alone/);
|
||||
@@ -161,7 +161,7 @@ test("grants: projects is [\"*\"] or distinct lowercase UUIDs", () => {
|
||||
});
|
||||
|
||||
test("grants: instances is [\"*\"] or distinct declared instances of that business", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["coder"]; }), context()), /grants\[0\]\.instances\[0\] must be "\*" or a declared instance of creative-hub \(got "coder"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["coder"]; }), context()), /grants\[0\]\.instances\[0\] must be "\*" or a declared instance of the grant's business/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["*", "writer"]; }), context()), /grants\[0\]\.instances must be \["\*"\] alone/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["writer", "writer"]; }), context()), /duplicate entry in grants\[0\]\.instances/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = []; }), context()), /grants\[0\]\.instances must not be empty/);
|
||||
@@ -172,7 +172,7 @@ test("grants: any level other than collaborator or viewer refuses, naming grants
|
||||
for (const bad of ["owner", "Collaborator", "COLLABORATOR", "Viewer", "", null, 1]) {
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].level = bad; }), context()), /grants\[1\]\.level must be one of collaborator, viewer/);
|
||||
}
|
||||
refuses(() => checkAccess(mutated((d) => { delete d.grants[0].level; }), context()), /grants\[0\]\.level must be one of collaborator, viewer \(got undefined\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { delete d.grants[0].level; }), context()), /grants\[0\]\.level must be one of collaborator, viewer/);
|
||||
});
|
||||
|
||||
test("grants: expires is optional ISO time, and a past expiry is not refused", () => {
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
// #1873 Phase A slice A2: the root agent file's writer and the project-agent writer verb (design
|
||||
// docs/plans/2026-11-10_agent-levels-design.md sections 6, 7 and 9 item 1).
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { loadRootAgents } from "../src/agent-levels.mjs";
|
||||
import {
|
||||
ABSENT_REVISION, ROOT_LOCK, ROOT_RECEIPTS, changeRootAgent, patchRootEntry, projectAgentProblem, readRootAgentsFile, rootAgentSummary,
|
||||
} from "../src/agent-levels-write.mjs";
|
||||
import * as oldPath from "../../business/src/agent-levels-write.mjs";
|
||||
import { organizationWriter } from "../src/organization.mjs";
|
||||
import { businessDoc, REPO_ROLES, rolesCopy, tmp, writeJson } from "./helpers.mjs";
|
||||
|
||||
const rolesDir = REPO_ROLES;
|
||||
const SECRET = "sk-test-NOT-A-SECRET-1234";
|
||||
const OWNER = Object.freeze({ provider: "local-dev", iss: "local-dev", sub: "local-dev-owner" });
|
||||
const as = (actor = OWNER) => ({ actor, owner: OWNER, rolesDir });
|
||||
|
||||
function config() {
|
||||
const dir = join(tmp(), "config");
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
chmodSync(dir, 0o700);
|
||||
return dir;
|
||||
}
|
||||
const file = dir => join(dir, "agents.json");
|
||||
const bytes = dir => existsSync(file(dir)) ? readFileSync(file(dir), "utf8") : null;
|
||||
const receipts = dir => existsSync(join(dir, ROOT_RECEIPTS))
|
||||
? readFileSync(join(dir, ROOT_RECEIPTS), "utf8").trim().split("\n").filter(Boolean).map(line => JSON.parse(line)) : [];
|
||||
const seeded = async dir => {
|
||||
await changeRootAgent(dir, { revision: ABSENT_REVISION, name: "researcher", entry: { definition: "research", vars: { model: "claude-opus-5-5" } } }, as());
|
||||
return readRootAgentsFile(dir);
|
||||
};
|
||||
const refuses = (code, check = () => true) => error => error.code === code && check(error);
|
||||
|
||||
test("the old business path re-exports the writer", () => {
|
||||
assert.equal(oldPath.changeRootAgent, changeRootAgent);
|
||||
});
|
||||
|
||||
test("root writer: an owner write lands, reads back through A1's loader, and its receipt names the change", async () => {
|
||||
const dir = config();
|
||||
const receipt = await changeRootAgent(dir, { revision: ABSENT_REVISION, name: "researcher", entry: { definition: "research", vars: { model: "claude-opus-5-5" } } }, as());
|
||||
assert.equal(receipt.verb, "root.agent.set");
|
||||
assert.equal(receipt.agent, "researcher");
|
||||
assert.equal(receipt.definition, "research");
|
||||
assert.deepEqual(receipt.changed, ["added"]);
|
||||
assert.equal(receipt.revisionBefore, ABSENT_REVISION);
|
||||
const st = lstatSync(file(dir));
|
||||
assert.equal(st.mode & 0o777, 0o600);
|
||||
assert.equal(st.nlink, 1);
|
||||
const loaded = loadRootAgents(dir, { rolesDir });
|
||||
assert.equal(loaded.agents.researcher.vars.model, "claude-opus-5-5");
|
||||
const read = await readRootAgentsFile(dir);
|
||||
assert.equal(read.revision, receipt.revisionAfter);
|
||||
// A patch keeps the keys it leaves out and names, by key, what it changed.
|
||||
const second = await changeRootAgent(dir, { revision: read.revision, name: "researcher", entry: old => patchRootEntry(old, { vars: { "limits.network": "api-only" }, excludeOrgs: ["home"] }) }, as());
|
||||
assert.deepEqual(second.changed, ["vars.limits.network", "excludeOrgs"]);
|
||||
assert.deepEqual(rootAgentSummary((await readRootAgentsFile(dir)).document), [{ name: "researcher", definition: "research", model: "claude-opus-5-5", varKeys: ["limits.network"], excludeOrgs: ["home"] }]);
|
||||
const removed = await changeRootAgent(dir, { revision: second.revisionAfter, name: "researcher", entry: null }, as());
|
||||
assert.equal(removed.verb, "root.agent.remove");
|
||||
assert.deepEqual(removed.changed, ["removed"]);
|
||||
assert.deepEqual(loadRootAgents(dir, { rolesDir }).agents, {});
|
||||
const lines = receipts(dir);
|
||||
assert.equal(lines.length, 3);
|
||||
// Receipts carry names and key paths only: no var value, no host path.
|
||||
const text = readFileSync(join(dir, ROOT_RECEIPTS), "utf8");
|
||||
assert.ok(!text.includes("claude-opus-5-5") && !text.includes("api-only") && !text.includes(dir));
|
||||
assert.ok(lines.every(line => line.actor === "owner" && line.uid === process.geteuid()));
|
||||
assert.equal(lstatSync(join(dir, ROOT_RECEIPTS)).mode & 0o777, 0o600);
|
||||
assert.equal(existsSync(join(dir, "agents.lock")), false);
|
||||
assert.deepEqual(readdirSync(dir).filter(n => n.startsWith(".agents-")), []);
|
||||
});
|
||||
|
||||
test("root writer: every actor but the owner refuses human-required and nothing is written", async () => {
|
||||
const dir = config();
|
||||
const others = [
|
||||
{ provider: "local-dev", iss: "local-dev", sub: "chief" },
|
||||
{ provider: "local-dev", iss: "local-dev", sub: "chief-of-staff.chief" },
|
||||
{ provider: "local-dev", iss: "local-dev", sub: "tester" },
|
||||
{ ...OWNER, provider: "tester" },
|
||||
{ provider: "https-idp", iss: "https://idp.example", sub: "local-dev-owner" },
|
||||
"local-dev-owner", "pm", null, undefined, {},
|
||||
];
|
||||
for (const actor of others) {
|
||||
await assert.rejects(changeRootAgent(dir, { revision: ABSENT_REVISION, name: "researcher", entry: { definition: "research" } }, { actor, owner: OWNER, rolesDir }), refuses("human-required"), JSON.stringify(actor ?? null));
|
||||
}
|
||||
// An owner context that names the tester is not an owner either.
|
||||
const tester = { provider: "local-dev", iss: "local-dev", sub: "tester" };
|
||||
await assert.rejects(changeRootAgent(dir, { revision: ABSENT_REVISION, name: "researcher", entry: { definition: "research" } }, { actor: tester, owner: tester, rolesDir }), refuses("human-required"));
|
||||
await assert.rejects(changeRootAgent(dir, { revision: ABSENT_REVISION, name: "researcher", entry: { definition: "research" } }, { actor: OWNER, rolesDir }), refuses("human-required"));
|
||||
assert.equal(bytes(dir), null);
|
||||
assert.deepEqual(receipts(dir), []);
|
||||
});
|
||||
|
||||
test("root writer: a stale revision refuses and the file is unchanged", async () => {
|
||||
const dir = config();
|
||||
const first = await seeded(dir);
|
||||
const before = bytes(dir);
|
||||
await assert.rejects(changeRootAgent(dir, { revision: ABSENT_REVISION, name: "reviewer", entry: { definition: "reviewer" } }, as()), refuses("revision-conflict"));
|
||||
await changeRootAgent(dir, { revision: first.revision, name: "reviewer", entry: { definition: "reviewer" } }, as());
|
||||
const after = bytes(dir);
|
||||
// The first revision is now stale too.
|
||||
await assert.rejects(changeRootAgent(dir, { revision: first.revision, name: "pm", entry: { definition: "pm" } }, as()), refuses("revision-conflict"));
|
||||
assert.notEqual(before, after);
|
||||
assert.equal(bytes(dir), after);
|
||||
await assert.rejects(changeRootAgent(dir, { revision: "nope", name: "pm", entry: { definition: "pm" } }, as()), refuses("revision-conflict"));
|
||||
});
|
||||
|
||||
test("root writer: a symbolic link, a group-writable file and a foreign uid refuse and the file is unchanged", async () => {
|
||||
const dir = config();
|
||||
const { revision } = await seeded(dir);
|
||||
const original = bytes(dir);
|
||||
// A symbolic link in place of the file: the target is never followed or written.
|
||||
const target = join(dir, "elsewhere.json");
|
||||
writeFileSync(target, original); chmodSync(target, 0o600);
|
||||
const real = join(dir, "real.json");
|
||||
writeFileSync(real, original); chmodSync(real, 0o600);
|
||||
const moved = readFileSync(file(dir));
|
||||
writeFileSync(real, moved);
|
||||
const { renameSync } = await import("node:fs");
|
||||
renameSync(file(dir), join(dir, "kept.json"));
|
||||
symlinkSync(target, file(dir));
|
||||
await assert.rejects(changeRootAgent(dir, { revision, name: "pm", entry: { definition: "pm" } }, as()), refuses("agents-unsafe", e => !e.message.includes(dir)));
|
||||
assert.equal(readFileSync(target, "utf8"), original);
|
||||
assert.ok(lstatSync(file(dir)).isSymbolicLink());
|
||||
await assert.rejects(readRootAgentsFile(dir), refuses("agents-unsafe"));
|
||||
// Restore, then make it group-writable.
|
||||
const { unlinkSync } = await import("node:fs");
|
||||
unlinkSync(file(dir)); renameSync(join(dir, "kept.json"), file(dir));
|
||||
chmodSync(file(dir), 0o620);
|
||||
await assert.rejects(changeRootAgent(dir, { revision, name: "pm", entry: { definition: "pm" } }, as()), refuses("agents-unsafe", e => /group or other/.test(e.message)));
|
||||
assert.equal(bytes(dir), original);
|
||||
assert.equal(lstatSync(file(dir)).mode & 0o777, 0o620);
|
||||
chmodSync(file(dir), 0o600);
|
||||
// A second link to the file refuses: a write through one name would not be the file another reads.
|
||||
const { linkSync } = await import("node:fs");
|
||||
linkSync(file(dir), join(dir, "alias.json"));
|
||||
await assert.rejects(changeRootAgent(dir, { revision, name: "pm", entry: { definition: "pm" } }, as()), refuses("agents-unsafe", e => /one link/.test(e.message)));
|
||||
unlinkSync(join(dir, "alias.json"));
|
||||
// Not this process's uid: the directory checks refuse first (an ancestor this uid owns is foreign).
|
||||
await assert.rejects(changeRootAgent(dir, { revision, name: "pm", entry: { definition: "pm" } }, { ...as(), uid: process.geteuid() + 1 }), refuses("agents-unsafe", e => /config directory/.test(e.message) && !/root agent file/.test(e.message)));
|
||||
await assert.rejects(readRootAgentsFile(dir, { uid: process.geteuid() + 1 }), refuses("agents-unsafe", e => /config directory/.test(e.message) && !/root agent file/.test(e.message)));
|
||||
assert.equal(bytes(dir), original);
|
||||
// A file another uid owns, in a directory that is this uid's: the file's own owner check refuses.
|
||||
// Changing a file's owner needs root, so the descriptor stat reports a foreign uid through the seam.
|
||||
const foreign = async fd => { const st = await fd.stat(); return Object.assign(Object.create(Object.getPrototypeOf(st)), st, { uid: st.uid + 1 }); };
|
||||
const receiptsBefore = existsSync(join(dir, ROOT_RECEIPTS)) ? readFileSync(join(dir, ROOT_RECEIPTS), "utf8") : null;
|
||||
await assert.rejects(changeRootAgent(dir, { revision, name: "pm", entry: { definition: "pm" } }, { ...as(), statFile: foreign }), refuses("agents-unsafe", e => /root agent file must belong to uid/.test(e.message)));
|
||||
await assert.rejects(readRootAgentsFile(dir, { statFile: foreign }), refuses("agents-unsafe", e => /root agent file must belong to uid/.test(e.message)));
|
||||
assert.equal(bytes(dir), original);
|
||||
assert.equal(lstatSync(file(dir)).mode & 0o777, 0o600);
|
||||
assert.equal(existsSync(join(dir, ROOT_RECEIPTS)) ? readFileSync(join(dir, ROOT_RECEIPTS), "utf8") : null, receiptsBefore);
|
||||
assert.equal(existsSync(join(dir, ROOT_LOCK)), false, "the lock is released");
|
||||
// A group-writable config directory refuses before any lock or read.
|
||||
chmodSync(dir, 0o770);
|
||||
await assert.rejects(changeRootAgent(dir, { revision, name: "pm", entry: { definition: "pm" } }, as()), refuses("agents-unsafe", e => /config directory/.test(e.message)));
|
||||
chmodSync(dir, 0o700);
|
||||
// An ancestor another user can write without the sticky bit refuses: that user could rename the
|
||||
// config directory between the checks and the write. With the sticky bit, as /tmp has, it can't.
|
||||
const parent = dirname(dir);
|
||||
const mode = lstatSync(parent).mode & 0o7777;
|
||||
chmodSync(parent, 0o777);
|
||||
try {
|
||||
await assert.rejects(changeRootAgent(dir, { revision, name: "pm", entry: { definition: "pm" } }, as()), refuses("agents-unsafe", e => /ancestor/.test(e.message) && !e.message.includes(dir)));
|
||||
await assert.rejects(readRootAgentsFile(dir), refuses("agents-unsafe", e => /ancestor/.test(e.message)));
|
||||
chmodSync(parent, 0o1777);
|
||||
assert.equal((await readRootAgentsFile(dir)).revision, revision);
|
||||
} finally { chmodSync(parent, mode); }
|
||||
assert.equal(bytes(dir), original);
|
||||
assert.equal(receipts(dir).length, 1);
|
||||
assert.equal(existsSync(join(dir, "agents.lock")), false);
|
||||
});
|
||||
|
||||
test("root writer: an invalid document refuses with the key named and the value absent", async () => {
|
||||
const dir = config();
|
||||
const { revision } = await seeded(dir);
|
||||
const original = bytes(dir);
|
||||
const cases = [
|
||||
[{ definition: "research", vars: { model: SECRET.repeat(10) } }, /variable model/],
|
||||
[{ definition: "research", vars: { "limits.network": SECRET } }, /variable limits\.network/],
|
||||
[{ definition: SECRET }, /root agents\.leaky\.definition/],
|
||||
[{ definition: "research", excludeOrgs: [SECRET.toUpperCase()] }, /root agents\.leaky\.excludeOrgs/],
|
||||
[{ definition: "research", vars: { [SECRET]: "x" } }, /root agents\.leaky\.vars/],
|
||||
[{ definition: "research", credentials: { gitea: SECRET } }, /root agents\.leaky\.credentials/],
|
||||
[{ definition: "chief-of-staff" }, /chief-of-staff can't be a root agent/],
|
||||
[{ definition: "assistant" }, /assistant definition is the root agent named assistant only/],
|
||||
];
|
||||
for (const [entry, pattern] of cases) {
|
||||
await assert.rejects(changeRootAgent(dir, { revision, name: "leaky", entry }, as()),
|
||||
refuses("agents-invalid", e => pattern.test(e.message) && !e.message.includes(SECRET) && !e.message.includes(SECRET.toUpperCase()) && !e.message.includes(dir)),
|
||||
JSON.stringify(Object.keys(entry)));
|
||||
}
|
||||
await assert.rejects(changeRootAgent(dir, { revision, name: SECRET.toUpperCase(), entry: { definition: "research" } }, as()), refuses("agents-invalid", e => !e.message.includes(SECRET.toUpperCase())));
|
||||
await assert.rejects(changeRootAgent(dir, { revision, name: "absent", entry: null }, as()), refuses("agent-not-found"));
|
||||
await assert.rejects(changeRootAgent(dir, { revision, name: "researcher", entry: { definition: "research", vars: { model: "claude-opus-5-5" } } }, as()), refuses("no-change"));
|
||||
assert.equal(bytes(dir), original);
|
||||
assert.equal(receipts(dir).length, 1);
|
||||
});
|
||||
|
||||
test("root writer: a crash between the temp file and the rename leaves the old file whole", async () => {
|
||||
const dir = config();
|
||||
const { revision } = await seeded(dir);
|
||||
const original = bytes(dir);
|
||||
await assert.rejects(changeRootAgent(dir, { revision, name: "reviewer", entry: { definition: "reviewer" } }, { ...as(), beforeReplace: () => { throw new Error("crash"); } }), /crash/);
|
||||
assert.equal(bytes(dir), original);
|
||||
assert.deepEqual(readdirSync(dir).filter(n => n.startsWith(".agents-")), []);
|
||||
assert.equal(existsSync(join(dir, "agents.lock")), false);
|
||||
assert.equal(receipts(dir).length, 1);
|
||||
// A writer that ignores the lock and changes the file between the temp and the rename loses.
|
||||
await assert.rejects(changeRootAgent(dir, { revision, name: "reviewer", entry: { definition: "reviewer" } }, { ...as(), beforeReplace: () => writeFileSync(file(dir), original.replace("research\"", "research\" ")) }), refuses("revision-conflict"));
|
||||
// A new file is never published over one created meanwhile.
|
||||
const fresh = config();
|
||||
await assert.rejects(changeRootAgent(fresh, { revision: ABSENT_REVISION, name: "pm", entry: { definition: "pm" } }, { ...as(), beforeReplace: () => writeJson(file(fresh), { agentsVersion: 1, agents: {} }) }), refuses("revision-conflict"));
|
||||
assert.deepEqual(JSON.parse(bytes(fresh)), { agentsVersion: 1, agents: {} });
|
||||
// A failure after the rename says the change landed.
|
||||
const landed = config();
|
||||
await assert.rejects(changeRootAgent(landed, { revision: ABSENT_REVISION, name: "pm", entry: { definition: "pm" } }, { ...as(), syncDirectory: async () => { throw new Error("disk"); } }), e => e.committed === true);
|
||||
assert.ok(loadRootAgents(landed, { rolesDir }).agents.pm);
|
||||
});
|
||||
|
||||
test("root writer: a held lock refuses agents-locked", async () => {
|
||||
const dir = config();
|
||||
writeFileSync(join(dir, "agents.lock"), "");
|
||||
await assert.rejects(changeRootAgent(dir, { revision: ABSENT_REVISION, name: "pm", entry: { definition: "pm" } }, as()), refuses("agents-locked"));
|
||||
assert.equal(bytes(dir), null);
|
||||
});
|
||||
|
||||
// The project-agent verb: the organization file's projects.<p>.agents, one entry per call.
|
||||
function org(mutate = () => {}) {
|
||||
const root = tmp();
|
||||
const doc = businessDoc(root);
|
||||
mutate(doc);
|
||||
const dir = join(root, "config");
|
||||
const orgFile = writeJson(join(dir, "businesses/acme.json"), doc);
|
||||
chmodSync(dir, 0o700);
|
||||
const roles = rolesCopy(root);
|
||||
return { dir, orgFile, roles, writer: organizationWriter({ dir, rolesDir: roles }) };
|
||||
}
|
||||
const agentsOf = (writer, project = "stack") => writer.read("acme").document.projects[project].agents;
|
||||
const history = (writer) => readFileSync(writer.logFile("acme"), "utf8");
|
||||
|
||||
test("project verb: each entry form lands, the log names paths only, and remove drops the key", () => {
|
||||
const { writer } = org();
|
||||
let { revision } = writer.read("acme");
|
||||
const step = (name, entry) => ({ revision } = writer.setProjectAgent("acme", { revision, project: "stack", name, entry }, "jason"));
|
||||
step("checker", { definition: "fact-checker", vars: { model: "claude-sonnet-5-5" } });
|
||||
assert.deepEqual(agentsOf(writer), { checker: { definition: "fact-checker", vars: { model: "claude-sonnet-5-5" } } });
|
||||
step("coder", { vars: { "limits.network": "none" } });
|
||||
step("reviewer", { exclude: true });
|
||||
assert.deepEqual(Object.keys(agentsOf(writer)).sort(), ["checker", "coder", "reviewer"]);
|
||||
step("checker", null);
|
||||
step("coder", null);
|
||||
step("reviewer", null);
|
||||
assert.equal(writer.read("acme").document.projects.stack.agents, undefined);
|
||||
const events = history(writer).trim().split("\n").map(line => JSON.parse(line)).filter(e => e.state === "committed");
|
||||
assert.deepEqual(events.map(e => e.detail.form), ["define", "narrow", "exclude", "remove", "remove", "remove"]);
|
||||
assert.ok(events.every(e => e.detail.verb === "project.agents.set" && e.detail.project === "stack" && e.actor === "jason"));
|
||||
assert.ok(events.every(e => e.changes.every(c => !("old" in c) && !("new" in c))));
|
||||
assert.ok(!history(writer).includes("claude-sonnet-5-5"));
|
||||
});
|
||||
|
||||
test("project verb: a narrow that widens refuses, and refusals never quote a value", () => {
|
||||
const { writer, orgFile } = org();
|
||||
const { revision } = writer.read("acme");
|
||||
const before = readFileSync(orgFile, "utf8");
|
||||
const refusals = [
|
||||
[{ vars: { model: SECRET } }, "project-agents-invalid", /can only narrow/],
|
||||
[{ vars: { "limits.network": SECRET } }, "project-agents-invalid", /variable limits\.network/],
|
||||
[{ definition: "fact-checker", vars: { model: SECRET.repeat(10) } }, "project-agents-invalid", /variable model/],
|
||||
[{ definition: SECRET }, "project-agents-invalid", /projects\.stack\.agents\.coder\.definition/],
|
||||
[{ definition: "fact-checker", credentials: { gitea: SECRET } }, "project-agents-invalid", /credentials/],
|
||||
[{ exclude: false }, "project-agents-invalid", /exclude entry/],
|
||||
[{}, "project-agents-invalid", /defines \(definition\), narrows \(vars\) or excludes/],
|
||||
];
|
||||
for (const [entry, code, pattern] of refusals) {
|
||||
assert.throws(() => writer.setProjectAgent("acme", { revision, project: "stack", name: "coder", entry }, "jason"),
|
||||
e => e.code === code && pattern.test(e.message) && !e.message.includes(SECRET), JSON.stringify(entry));
|
||||
}
|
||||
// A narrow or exclude that names nothing it could act on refuses (the union check).
|
||||
assert.throws(() => writer.setProjectAgent("acme", { revision, project: "stack", name: "ghost", entry: { exclude: true } }, "jason"), e => e.code === "project-agents-invalid" && /names no root or organization agent/.test(e.message));
|
||||
assert.throws(() => writer.setProjectAgent("acme", { revision, project: "stack", name: "ghost", entry: null }, "jason"), { code: "agent-not-found" });
|
||||
assert.equal(readFileSync(orgFile, "utf8"), before);
|
||||
});
|
||||
|
||||
test("project verb: the chief and the assistant refuse", () => {
|
||||
const { writer, orgFile } = org(doc => { doc.roles.assistant = { definition: "assistant" }; });
|
||||
const { document, revision } = writer.read("acme");
|
||||
assert.equal(document.roles.assistant.definition, "assistant", "the fixture has an organization assistant");
|
||||
assert.equal(document.roles.chief.definition, "chief-of-staff", "the fixture has a chief");
|
||||
const before = readFileSync(orgFile, "utf8");
|
||||
const protectedAgent = /a project can't change the organization's (chief-of-staff|assistant)/;
|
||||
const notProjectAgent = /can't be a project agent/;
|
||||
for (const [name, entry, reason] of [
|
||||
["chief", { exclude: true }, protectedAgent], ["chief", { vars: { "limits.network": "none" } }, protectedAgent], ["chief", { definition: "reviewer" }, protectedAgent],
|
||||
["assistant", { exclude: true }, protectedAgent], ["assistant", { vars: { "limits.network": "none" } }, protectedAgent], ["assistant", { definition: "reviewer" }, protectedAgent],
|
||||
["helper", { definition: "chief-of-staff" }, notProjectAgent], ["helper", { definition: "assistant" }, notProjectAgent],
|
||||
]) {
|
||||
assert.throws(() => writer.setProjectAgent("acme", { revision, project: "stack", name, entry }, "jason"), e => e.code === "project-agents-invalid" && reason.test(e.message), `${name} ${JSON.stringify(entry)}`);
|
||||
}
|
||||
assert.equal(readFileSync(orgFile, "utf8"), before);
|
||||
});
|
||||
|
||||
test("project verb: every agent actor and every non-owner refuses human-required", () => {
|
||||
const { writer, orgFile } = org();
|
||||
const { revision } = writer.read("acme");
|
||||
const before = readFileSync(orgFile, "utf8");
|
||||
for (const actor of ["pm", "pm.pm", "chief", "chief-of-staff.chief", "coder", "tester", "local-dev-owner", "someone"]) {
|
||||
assert.throws(() => writer.setProjectAgent("acme", { revision, project: "stack", name: "checker", entry: { definition: "fact-checker" } }, actor), { code: "human-required" }, actor);
|
||||
}
|
||||
assert.equal(readFileSync(orgFile, "utf8"), before);
|
||||
});
|
||||
|
||||
test("project verb: only projects.<p>.agents.<name> may change; the projects fence keeps every other writer out", () => {
|
||||
const { writer, orgFile, roles } = org();
|
||||
const { document, revision } = writer.read("acme");
|
||||
const before = readFileSync(orgFile, "utf8");
|
||||
// The general writer can't touch project agents, or anything else under projects.
|
||||
const withAgents = structuredClone(document);
|
||||
withAgents.projects.stack.agents = { checker: { definition: "fact-checker" } };
|
||||
assert.throws(() => writer.write("acme", withAgents, { revision, actor: "jason" }), { code: "organization-projects-protected" });
|
||||
// The verb's guard refuses any candidate that changes another projects key.
|
||||
const root = { state: "absent", agents: {}, assistant: null };
|
||||
const args = { orgId: "acme", project: "stack", name: "checker", entry: { definition: "fact-checker" }, rolesDir: roles, root };
|
||||
const good = structuredClone(document);
|
||||
good.projects.stack.agents = { checker: { definition: "fact-checker" } };
|
||||
assert.equal(projectAgentProblem(document, good, args), null);
|
||||
const mutations = [
|
||||
d => { d.projects.stack.root = "/elsewhere"; },
|
||||
d => { d.projects.other = { root: "/elsewhere" }; },
|
||||
d => { d.projects.stack.agents.extra = { definition: "reviewer" }; },
|
||||
d => { d.human = "someone"; },
|
||||
d => { d.projects.stack.agents.checker = { definition: "reviewer" }; },
|
||||
];
|
||||
for (const mutate of mutations) {
|
||||
const next = structuredClone(good);
|
||||
mutate(next);
|
||||
assert.equal(projectAgentProblem(document, next, args)?.[0], "organization-projects-protected", mutate.toString());
|
||||
}
|
||||
// A missing project and a library project refuse.
|
||||
assert.throws(() => writer.setProjectAgent("acme", { revision, project: "nope", name: "checker", entry: { definition: "fact-checker" } }, "jason"), { code: "project-not-found" });
|
||||
const library = structuredClone(document);
|
||||
library.projects.stack.kind = "library";
|
||||
assert.equal(projectAgentProblem(library, good, args)?.[0], "project-agents-library");
|
||||
// A stale revision refuses.
|
||||
writer.setProjectAgent("acme", { revision, project: "stack", name: "checker", entry: { definition: "fact-checker" } }, "jason");
|
||||
assert.throws(() => writer.setProjectAgent("acme", { revision, project: "stack", name: "other", entry: { definition: "reviewer" } }, "jason"), { code: "revision-conflict" });
|
||||
assert.notEqual(readFileSync(orgFile, "utf8"), before);
|
||||
});
|
||||
|
||||
test("project verb: a root file that refuses refuses the project write", () => {
|
||||
const { writer, dir, orgFile } = org();
|
||||
writeJson(join(dir, "agents.json"), { agentsVersion: 2 });
|
||||
const { revision } = writer.read("acme");
|
||||
const before = readFileSync(orgFile, "utf8");
|
||||
assert.throws(() => writer.setProjectAgent("acme", { revision, project: "stack", name: "checker", entry: { definition: "fact-checker" } }, "jason"), e => e.code === "project-agents-invalid" && /root agent file/.test(e.message));
|
||||
assert.equal(readFileSync(orgFile, "utf8"), before);
|
||||
});
|
||||
|
||||
test("project verb: a narrow of a root agent checks against the root level", async () => {
|
||||
const { writer, dir, roles } = org();
|
||||
await changeRootAgent(dir, { revision: ABSENT_REVISION, name: "researcher", entry: { definition: "research" } }, { actor: OWNER, owner: OWNER, rolesDir: roles });
|
||||
const { revision } = writer.read("acme");
|
||||
writer.setProjectAgent("acme", { revision, project: "stack", name: "researcher", entry: { vars: { "limits.network": "none" } } }, "jason");
|
||||
assert.deepEqual(agentsOf(writer).researcher, { vars: { "limits.network": "none" } });
|
||||
});
|
||||
@@ -59,7 +59,7 @@ test("row 325: the old mosaic-stack project key refuses against those same proje
|
||||
const r = x.validate();
|
||||
assert.equal(r.status, 2, r.stdout + r.stderr);
|
||||
assert.equal(r.stdout, "");
|
||||
assert.match(r.stderr, /has id stack, but business mosaic-stack declares it as mosaic-stack/);
|
||||
assert.match(r.stderr, /has id that does not match the declared project key/);
|
||||
assert.ok(r.stderr.includes(x.projectFile));
|
||||
assert.doesNotMatch(r.stderr, /no project file/);
|
||||
});
|
||||
@@ -77,7 +77,7 @@ test("validate: project files", () => {
|
||||
assert.deepEqual(JSON.parse(r.out).projects, { stack: "valid" });
|
||||
|
||||
writeJson(file, { projectVersion: 1, id: "other" });
|
||||
assert.match(run(s, "validate", "acme").err, /has id other, but business acme declares it as stack/);
|
||||
assert.match(run(s, "validate", "acme").err, /has id that does not match the declared project key/);
|
||||
assert.equal(run(s, "validate", "acme").code, 2);
|
||||
writeJson(file, { projectVersion: 1, id: "stack", roles: { ghost: {} } });
|
||||
const ghost = run(s, "validate", "acme");
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
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 { checkAccess } from '../src/access.mjs';
|
||||
import { checkAppearance } from '../src/appearance.mjs';
|
||||
import { loadProject } from '../src/project.mjs';
|
||||
import { validateRoleDocument } from '../src/role.mjs';
|
||||
import { covers } from '../src/vocabulary.mjs';
|
||||
import { BusinessError } from '../src/errors.mjs';
|
||||
const context = { providers: ['test'], peers: [], businesses: { acme: { human: 'jason', instances: ['coder'] } }, ssoSubjects: [] };
|
||||
const access = () => ({ accessVersion: 1, revision: 1, users: [{ id: 'bean', home: 'local', subjects: [{ provider: 'test', sub: 'user' }] }], grants: [{ id: 'grant', user: 'bean', business: 'acme', projects: ['*'], instances: ['coder'], level: 'viewer' }] });
|
||||
for (const [field, edit] of [
|
||||
['users[0].home', d => { d.users[0].home = secret; }],
|
||||
['users[0].subjects[0].provider', d => { d.users[0].subjects[0].provider = secret; }],
|
||||
...['user', 'business', 'level'].map(k => [`grants[0].${k}`, d => { d.grants[0][k] = secret; }]),
|
||||
['grants[0].projects[0]', d => { d.grants[0].projects = [secret]; }],
|
||||
['accessVersion', d => { d.accessVersion = secret; }],
|
||||
]) test(`access ${field} omits the refused value`, () => {
|
||||
const d = access(); edit(d); assert.throws(() => checkAccess(d, context), safe(field, BusinessError, { exitCode: 2 }));
|
||||
});
|
||||
for (const k of ['theme', 'mode']) test(`appearance ${k} omits its value`, () => {
|
||||
assert.throws(() => checkAppearance({ [k]: secret }, 'appearance'), safe(k, BusinessError, { exitCode: 2 }));
|
||||
});
|
||||
test('project root omits its configured value', () => assert.throws(() => loadProject(secret), safe('project root', BusinessError, { exitCode: 2 })));
|
||||
test('covers names the level field, not its value', () => assert.throws(() => covers({}, {}, new Date(), { level: secret }), safe('level', TypeError)));
|
||||
const role = () => ({ roleVersion: 2, name: 'r', title: 'R', contract: 'r.md', tools: ['read'], network: 'none', authority: { withinRole: [], crossRole: [] }, credentials: [] });
|
||||
for (const [field, edit] of [
|
||||
['skill', d => { d.skills = [secret + '/']; }],
|
||||
['contract', d => { d.contract = secret; }],
|
||||
['tool', d => { d.tools = [secret]; }],
|
||||
['authority.withinRole', d => { d.authority.withinRole = [secret]; }],
|
||||
['hireable', d => { d.hireable = [secret.toLowerCase()]; }],
|
||||
['gitea scope', d => { d.credentials = [{ service: 'gitea', scopes: [secret] }]; }],
|
||||
['vikunja route group', d => { d.credentials = [{ service: 'vikunja', scopes: { [secret]: ['read'] } }]; }],
|
||||
['vikunja verb', d => { d.credentials = [{ service: 'vikunja', scopes: { tasks: [secret] } }]; }],
|
||||
['name', d => { d.name = secret.toLowerCase(); }],
|
||||
]) test(`role ${field} omits the configured value`, t => {
|
||||
const root = scratch(t); writeFileSync(join(root, 'r.md'), '# R\n\n## Authority\n');
|
||||
const d = role(); edit(d);
|
||||
assert.throws(() => validateRoleDocument(d, join(root, 'r.json')), safe(field, BusinessError, { exitCode: 2 }, secret.toLowerCase()));
|
||||
});
|
||||
// This branch is reachable only with already-validated service names; a sentinel service
|
||||
// is refused earlier. Pin the same no-value rule with an admissible service.
|
||||
test('creative service refusal omits the validated service list', t => {
|
||||
const root = scratch(t); writeFileSync(join(root, 'companion.md'), '# R\n\n## Authority\n');
|
||||
const d = { ...role(), name: 'companion', contract: 'companion.md', messaging: { sendTo: ['human'], receiveFrom: ['human'] }, credentials: [{ service: 'vikunja', scopes: { tasks: ['read_one'] } }] };
|
||||
assert.throws(() => validateRoleDocument(d, join(root, 'companion.json')), safe('credential', BusinessError, { exitCode: 2 }, 'vikunja'));
|
||||
});
|
||||
test('role duplicate skill omits the accepted list value', t => {
|
||||
const root = scratch(t); writeFileSync(join(root, 'r.md'), '# R\n\n## Authority\n');
|
||||
const d = { ...role(), skills: [secret, secret] };
|
||||
assert.throws(() => validateRoleDocument(d, join(root, 'r.json')), safe('role skills', BusinessError, { exitCode: 2 }));
|
||||
});
|
||||
for (const at of ['user', 'grant', 'scope', 'human']) test(`access ${at} duplicate/reserved refusal omits accepted ids`, () => {
|
||||
const value = secret.toLowerCase(), d = access(), ctx = structuredClone(context);
|
||||
if (at === 'user') { d.users[0].id = value; d.users.push({ ...structuredClone(d.users[0]), subjects: [{ provider: 'test', sub: 'second' }] }); }
|
||||
if (at === 'grant') { d.grants[0].id = value; d.grants.push(structuredClone(d.grants[0])); }
|
||||
if (at === 'scope') { ctx.businesses.acme.instances = [value]; d.grants[0].instances = [value, value]; }
|
||||
if (at === 'human') { ctx.businesses.acme.human = value; d.users[0].id = value; }
|
||||
assert.throws(() => checkAccess(d, ctx), safe(at === 'scope' ? 'instances' : '.id', BusinessError, { exitCode: 2 }, value));
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
// JSON.parse quotes a prefix near this fault on Node 26; check partial leaks too.
|
||||
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 { BusinessError } from '../src/errors.mjs';
|
||||
import { requireId, requirePositiveInt, requireDate } from '../src/util.mjs';
|
||||
import { checkAccess } from '../src/access.mjs';
|
||||
for (const [field, check, value] of [
|
||||
['id', requireId, secret], ['revision', requirePositiveInt, secret], ['rotateBy', requireDate, secret],
|
||||
// Arbitrary sentinel bytes cannot pass the date-shape check to reach this branch.
|
||||
['rotateBy', requireDate, '2026-02-30'],
|
||||
]) test(`${field} rejects ${check.name} input without its value`, () => {
|
||||
assert.throws(() => check(value, field), error => {
|
||||
safe(field, BusinessError, { exitCode: 2 })(error);
|
||||
assert.ok(!error.message.includes(value), error.message);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
function access() { return { accessVersion: 1, revision: 1, users: [{ id: 'bean', home: 'local', subjects: [{ provider: 'test', sub: 'user' }] }], grants: [{ id: 'grant', user: 'bean', business: 'acme', projects: ['*'], instances: ['coder'], level: 'viewer' }] }; }
|
||||
const context = { providers: ['test'], peers: [], businesses: { acme: { human: 'jason', instances: ['coder'] } }, ssoSubjects: [] };
|
||||
for (const [field, value] of [['expires', secret], ['expires', '2026-02-30T00:00:00Z'], ['instances', [secret]]]) test(`grant ${field} refusal omits its value ${JSON.stringify(value)}`, () => {
|
||||
const doc = access(); doc.grants[0][field] = value;
|
||||
assert.throws(() => checkAccess(doc, context), error => {
|
||||
safe(`grants[0].${field}`, BusinessError, { exitCode: 2 })(error);
|
||||
assert.ok(!error.message.includes(Array.isArray(value) ? value[0] : value), error.message);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
@@ -164,7 +164,7 @@ test("the record carries what the broker and launcher need", () => {
|
||||
const s = setup();
|
||||
const pm = resolve(s, "pm");
|
||||
assert.deepEqual(Object.keys(pm).sort(), [
|
||||
"business", "contract", "credentials", "definition", "digest", "holder", "instance", "launch", "limits", "project", "provenance", "skills", "tracker", "vars", "webSource",
|
||||
"business", "contract", "credentials", "definition", "digest", "holder", "instance", "launch", "limits", "project", "provenance", "rails", "skills", "tracker", "vars", "webSource",
|
||||
]);
|
||||
assert.equal(pm.contract, join(REPO_ROLES, "pm.md"));
|
||||
assert.equal(pm.holder, "sage");
|
||||
|
||||
@@ -174,7 +174,7 @@ test("row 64 skills: optional, distinct skill directory names, none on version 1
|
||||
assert.deepEqual(role.skills, ["ms-unslop", "ms_sdlc.v2"]);
|
||||
assert.ok(Object.isFrozen(role.skills));
|
||||
refuses(() => check({ skills: "ms-unslop" }), /role skills must be an array/);
|
||||
refuses(() => check({ skills: ["ms-unslop", "ms-unslop"] }), /duplicate entry in role skills: "ms-unslop"/);
|
||||
refuses(() => check({ skills: ["ms-unslop", "ms-unslop"] }), /duplicate entry in role skills/);
|
||||
for (const bad of [".", "..", "", "a/b", "../ms-unslop", "ms unslop", 7, null]) {
|
||||
refuses(() => check({ skills: [bad] }), /role skill must be a skill directory name/);
|
||||
}
|
||||
@@ -189,7 +189,7 @@ test("credentials: Gitea scopes", () => {
|
||||
refuses(() => gitea(["write:admin"]), /unsupported gitea scope/);
|
||||
refuses(() => gitea(["all"]), /unsupported gitea scope/);
|
||||
refuses(() => gitea(["sudo:repository"]), /unsupported gitea scope/);
|
||||
refuses(() => gitea(["read:issue", "write:issue"]), /name issue twice/);
|
||||
refuses(() => gitea(["read:issue", "write:issue"]), /name a category twice/);
|
||||
refuses(() => gitea([]), /must not be empty/);
|
||||
});
|
||||
|
||||
@@ -255,7 +255,7 @@ test("M1: a creative role file wider than read and answer is refused", () => {
|
||||
};
|
||||
assert.doesNotThrow(creative("ideas", { tools: ["read", "grep", "find", "ls", "web"] }));
|
||||
for (const tool of ["write", "edit", "bash"]) {
|
||||
refuses(creative("ideas", { tools: ["read", tool] }), new RegExp(`^creative role ideas may not name tools ${tool} `));
|
||||
refuses(creative("ideas", { tools: ["read", tool] }), /^creative role ideas may not name tools outside the allowed set /);
|
||||
}
|
||||
const gitea = [{ service: "gitea", scopes: ["read:user", "write:repository"] }];
|
||||
assert.doesNotThrow(creative("ideas", { authority: { withinRole: ["message.send", "library.write"], crossRole: [] }, credentials: gitea }));
|
||||
@@ -268,9 +268,9 @@ test("M1: a creative role file wider than read and answer is refused", () => {
|
||||
refuses(creative("proofreader", { authority: { withinRole: ["message.send"], crossRole: ["library.write"] } }), only);
|
||||
// Row 285: library.write with any credential but gitea refuses.
|
||||
refuses(creative("ideas", { authority: { withinRole: ["message.send", "library.write"], crossRole: [] }, credentials: [...gitea, { service: "vikunja", scopes: { tasks: ["read_one"] } }] }),
|
||||
/^creative role ideas may hold only the gitea credential \(got vikunja\)$/);
|
||||
/^creative role ideas may hold only the gitea credential$/);
|
||||
refuses(creative("ideas", { authority: { withinRole: ["message.send", "library.write"], crossRole: [] }, credentials: [{ service: "host" }] }),
|
||||
/^creative role ideas may hold only the gitea credential \(got host\)$/);
|
||||
/^creative role ideas may hold only the gitea credential$/);
|
||||
refuses(creative("continuity", { messaging: undefined }), /^creative role continuity requires "messaging"$/);
|
||||
refuses(creative("research", { messaging: { sendTo: ["human", "pm"], receiveFrom: ["human"] } }), /^creative role research messaging.sendTo may name only human$/);
|
||||
refuses(creative("research", { messaging: { sendTo: ["human"], receiveFrom: ["assistant"] } }), /^creative role research messaging.receiveFrom may name only human$/);
|
||||
@@ -307,14 +307,14 @@ test("hireable: a list naming a definition that holds any refused action refuses
|
||||
for (const action of REFUSED_FOR_HIRE) {
|
||||
for (const key of ["withinRole", "crossRole"]) {
|
||||
const dir = hireDir({ chief: { hireable: ["worker"] }, worker: { [key]: [action], ...(key === "crossRole" ? { withinRole: [] } : {}) } });
|
||||
refuses(() => loadRole(dir, "chief"), new RegExp(`hireable names worker, which holds ${action.replace(/\./g, "\\.")}$`));
|
||||
refuses(() => loadRole(dir, "chief"), /hireable names a definition which holds an action outside the hireable ceiling$/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("hireable: a mixed definition refuses, a clean list loads, and the named file's own hireable isn't read", () => {
|
||||
const mixed = hireDir({ chief: { hireable: ["worker"] }, worker: { withinRole: ["message.send", "role.launch"] } });
|
||||
refuses(() => loadRole(mixed, "chief"), /hireable names worker, which holds role\.launch/);
|
||||
refuses(() => loadRole(mixed, "chief"), /hireable names a definition which holds an action outside the hireable ceiling/);
|
||||
const clean = hireDir({
|
||||
chief: { hireable: ["worker", "helper"] },
|
||||
worker: { withinRole: [...HIRE_ALLOWED] },
|
||||
@@ -323,15 +323,15 @@ test("hireable: a mixed definition refuses, a clean list loads, and the named fi
|
||||
});
|
||||
assert.deepEqual(loadRole(clean, "chief").hireable, ["worker", "helper"]);
|
||||
assert.ok(Object.isFrozen(loadRole(clean, "chief").hireable));
|
||||
refuses(() => loadRole(clean, "helper"), /hireable names nobody, which has no role file/);
|
||||
refuses(() => loadRole(clean, "helper"), /hireable names a definition which has no role file/);
|
||||
});
|
||||
|
||||
test("hireable: a missing, version 1, duplicate or badly named entry refuses, and the key is optional", () => {
|
||||
refuses(() => loadRole(hireDir({ chief: { hireable: ["ghost"] } }), "chief"), /hireable names ghost, which has no role file/);
|
||||
refuses(() => loadRole(hireDir({ chief: { hireable: ["old"] }, old: 1 }), "chief"), /hireable names old, which is role version 1/);
|
||||
refuses(() => loadRole(hireDir({ chief: { hireable: ["ghost"] } }), "chief"), /hireable names a definition which has no role file/);
|
||||
refuses(() => loadRole(hireDir({ chief: { hireable: ["old"] }, old: 1 }), "chief"), /hireable names a definition which is role version 1/);
|
||||
// Row 213 (M10): a creative definition refuses by name, before its file is read.
|
||||
for (const name of CREATIVE_DEFINITIONS) {
|
||||
refuses(() => loadRole(hireDir({ chief: { hireable: [name] } }), "chief"), new RegExp(`hireable names ${name}, a creative definition only the human adds$`));
|
||||
refuses(() => loadRole(hireDir({ chief: { hireable: [name] } }), "chief"), /hireable names a creative definition only the human adds$/);
|
||||
}
|
||||
refuses(() => loadRole(hireDir({ chief: { hireable: ["w", "w"] }, w: {} }), "chief"), /duplicate entry in role hireable/);
|
||||
refuses(() => loadRole(hireDir({ chief: { hireable: ["../w"] } }), "chief"), /role hireable entry/);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { REPO_ROLES } from "./helpers.mjs";
|
||||
@@ -41,3 +42,22 @@ test("the assistant summarizes a spec review in its own words and doesn't repeat
|
||||
// The user's own rulings still go to the chief of staff verbatim; only a review result is summarized.
|
||||
assert.match(text, /Relay each ruling the user gives verbatim to the chief\s+of\s+staff/);
|
||||
});
|
||||
|
||||
// TI: exact authorized contract wording, independently pinned for review.
|
||||
test("TI contracts point at the generated and live caller rails", () => {
|
||||
const pins = {
|
||||
"coder": "9a31e19cbea5953fd9598def446e0e5e351869498f593f46468cf03fc0e2759b",
|
||||
"cto": "c4c53b00eea1a6c73c90f2b5a9e54960848972a517c4e6ee47198e91dc8ddf45",
|
||||
"merge-gate": "8d39144cab994781cadf7ac0dbfc41a1dd8a3c59ef66d916c79e9d63324572ad",
|
||||
"pm": "0f7cbae8085709ab2907483543681ff7dc002ac2f9fea14d0b840213e9cf6d56",
|
||||
"reviewer": "5269da2de779f3e399838a1c8357201862a4bd25c03de71ca8ebb31978dea6b7",
|
||||
"security-reviewer": "eb664413c47d405ffb593cf15d3278217f09c74ddef0a179dcf22d9bf041378a",
|
||||
};
|
||||
for (const [name, hash] of Object.entries(pins)) {
|
||||
const text = contract(name);
|
||||
assert.equal(createHash('sha256').update(text).digest('hex'), hash, name);
|
||||
assert.match(text, /"Your\s+rails"/);
|
||||
assert.match(text, /`my_rails`/);
|
||||
assert.doesNotMatch(text, /launch record you\s+received/);
|
||||
}
|
||||
});
|
||||
@@ -27,7 +27,7 @@ function jsonProblems(kind, text, relative, problems) {
|
||||
const value = JSON.parse(text);
|
||||
for (const problem of validateRecord(kind, value)) problems.push(`${relative}: ${problem}`);
|
||||
return value;
|
||||
} catch (e) { problems.push(`${relative}: invalid JSON (${e.message})`); return null; }
|
||||
} catch { problems.push(`${relative}: invalid JSON`); return null; }
|
||||
}
|
||||
async function checkLayout(context) {
|
||||
const repo = projectRepo(context), problems = [];
|
||||
|
||||
@@ -67,7 +67,7 @@ export function validateInterviewTemplate(value) {
|
||||
value.questions.forEach((q, i) => {
|
||||
if (!object(q) || !sameKeys(q, ['key', 'text'])) { problems.push(`questions[${i}] keys must be exactly key, text`); return; }
|
||||
if (typeof q.key !== 'string' || !KEY.test(q.key)) problems.push(`questions[${i}].key must be lower-case letters, digits and dashes`);
|
||||
else if (seen.has(q.key)) problems.push(`questions[${i}].key repeats ${q.key}`);
|
||||
else if (seen.has(q.key)) problems.push(`questions[${i}].key repeats an earlier question key`);
|
||||
seen.add(q.key);
|
||||
if (!line(q.text, 500)) problems.push(`questions[${i}].text must be one line of 1-500 characters`);
|
||||
});
|
||||
@@ -76,7 +76,7 @@ export function validateInterviewTemplate(value) {
|
||||
problems.push('prompt must be 1-200 lines of text, each at most 2000 characters without line breaks');
|
||||
else {
|
||||
const text = value.prompt.join('\n'), used = new Set([...text.matchAll(/\{([^{}]*)\}/g)].map(m => m[1]));
|
||||
for (const name of used) if (!PLACEHOLDERS.includes(name)) problems.push(`prompt uses unknown placeholder {${name}}`);
|
||||
for (const name of used) if (!PLACEHOLDERS.includes(name)) problems.push(`prompt uses unknown placeholder; expected one of ${PLACEHOLDERS.join(", ")}`);
|
||||
for (const name of REQUIRED) if (!used.has(name)) problems.push(`prompt must use {${name}}`);
|
||||
if (!text.trim()) problems.push('prompt must not be empty');
|
||||
}
|
||||
@@ -87,7 +87,7 @@ export function validateInterviewTemplate(value) {
|
||||
export function loadInterviewTemplate(url = INTERVIEW_TEMPLATE_URL) {
|
||||
let value;
|
||||
try { value = JSON.parse(readFileSync(url, 'utf8')); }
|
||||
catch (error) { throw new OnboardingError('onboarding-template-invalid', `interview template unreadable: ${error.message}`); }
|
||||
catch (error) { throw new OnboardingError('onboarding-template-invalid', `interview template unreadable: expected readable JSON in ${url}${error.code ? ` (${error.code})` : ""}`); }
|
||||
const problems = validateInterviewTemplate(value);
|
||||
if (problems.length) throw new OnboardingError('onboarding-template-invalid', problems.join('; '));
|
||||
return value;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
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 { loadInterviewTemplate, OnboardingError } from '../src/onboarding.mjs';
|
||||
import { initProject, writeRecord, ProjectError } from '../src/index.mjs';
|
||||
test('onboarding template parser omits file bytes and keeps its code', t => {
|
||||
const file = join(scratch(t), 'template.json'); writeFileSync(file, secret);
|
||||
assert.throws(() => loadInterviewTemplate(file), safe(file, OnboardingError, { code: 'onboarding-template-invalid', stage: 'check' }));
|
||||
});
|
||||
test('project nonobject record already reports only its type (BASE control)', async t => {
|
||||
const root = scratch(t), context = { business: { id: 'acme', human: 'jason', projects: { stack: { root: join(root, 'code') } } }, project: 'stack', dataRoot: join(root, 'data') };
|
||||
await initProject(context);
|
||||
await assert.rejects(() => writeRecord({ ...context, path: 'requirements/REQ-1.json', record: JSON.stringify(secret), author: { name: 'Tester', email: '[email protected]' } }), safe('object', ProjectError, { code: 'project-record-invalid' }));
|
||||
});
|
||||
import { INTERVIEW_TEMPLATE_URL } from '../src/onboarding.mjs';
|
||||
for (const at of ['question', 'placeholder']) test(`template ${at} semantic refusal omits its value`, t => {
|
||||
const d = JSON.parse(readFileSync(INTERVIEW_TEMPLATE_URL, 'utf8'));
|
||||
if (at === 'question') { d.questions[0].key = secret.toLowerCase(); d.questions[1].key = secret.toLowerCase(); }
|
||||
else d.prompt.push(`{${secret}}`);
|
||||
const file = join(scratch(t), 'template.json'); writeFileSync(file, JSON.stringify(d));
|
||||
assert.throws(() => loadInterviewTemplate(file), safe(at === 'question' ? 'questions[1].key' : 'placeholder', OnboardingError, { code: 'onboarding-template-invalid' }, secret.toLowerCase()));
|
||||
});
|
||||
@@ -46,12 +46,12 @@ test('a replacement template is checked key by key and every problem is named',
|
||||
[t => { t.drafts.requirementPrefix = '-x'; }, 'drafts.requirementPrefix must start a record id'],
|
||||
[t => { delete t.drafts.prd; }, 'drafts keys must be exactly'],
|
||||
[t => { t.questions = []; }, 'questions must list 1-12 questions'],
|
||||
[t => { t.questions[1].key = t.questions[0].key; }, 'questions[1].key repeats outcome'],
|
||||
[t => { t.questions[1].key = t.questions[0].key; }, 'questions[1].key repeats an earlier question key'],
|
||||
[t => { t.questions[0].key = 'Bad Key'; }, 'questions[0].key must be lower-case'],
|
||||
[t => { t.questions[0].text = ''; }, 'questions[0].text must be one line'],
|
||||
[t => { t.questions[0].extra = 1; }, 'questions[0] keys must be exactly key, text'],
|
||||
[t => { t.prompt = ['line\nbreak']; }, 'prompt must be 1-200 lines'],
|
||||
[t => { t.prompt.push('{secret}'); }, 'prompt uses unknown placeholder {secret}'],
|
||||
[t => { t.prompt.push('{secret}'); }, 'prompt uses unknown placeholder; expected one of'],
|
||||
[t => { t.prompt = t.prompt.map(l => l.replaceAll('{questions}', 'the questions')); }, 'prompt must use {questions}'],
|
||||
[t => { t.prompt = t.prompt.map(l => l.replaceAll('{northStarDraft}', 'a doc')); }, 'prompt must use {northStarDraft}'],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
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 { checkProject, initProject } from '../src/index.mjs';
|
||||
test('project record JSON problems name the file without parser bytes', async t => {
|
||||
const root = scratch(t), context = { business: { id: 'acme', human: 'jason', projects: { stack: { root: join(root, 'code') } } }, project: 'stack', dataRoot: join(root, 'data') };
|
||||
const { repo } = await initProject(context);
|
||||
write(join(repo, 'requirements/REQ-1.json'));
|
||||
const result = await checkProject(context);
|
||||
assert.equal(result.ok, false);
|
||||
assert.ok(result.problems.some(p => p.includes('requirements/REQ-1.json: invalid JSON')), JSON.stringify(result.problems));
|
||||
assert.doesNotMatch(JSON.stringify(result), /sk-test|Unexpected token/);
|
||||
});
|
||||
@@ -99,18 +99,18 @@ export function checkCellText(v, what, opts) {
|
||||
|
||||
// An op id a caller chose: 8 to 72 characters, not ending in `.outcome`.
|
||||
export function checkCallerOpId(v, what = "op id") {
|
||||
if (typeof v !== "string" || !CALLER_OP_RE.test(v)) throw refuse(`${what} ${JSON.stringify(v)} must match ${CALLER_OP_RE.source} (8 to 72 characters)`);
|
||||
if (v.endsWith(".outcome")) throw refuse(`${what} ${JSON.stringify(v)} ends in .outcome, which is reserved`);
|
||||
if (typeof v !== "string" || !CALLER_OP_RE.test(v)) throw refuse(`${what} must match ${CALLER_OP_RE.source} (8 to 72 characters)`);
|
||||
if (v.endsWith(".outcome")) throw refuse(`${what} ends in .outcome, which is reserved`);
|
||||
return v;
|
||||
}
|
||||
|
||||
export function checkName(v, what) {
|
||||
if (typeof v !== "string" || !NAME_RE.test(v)) throw refuse(`${what} must be a seat name (lowercase letters, digits, hyphens): ${JSON.stringify(v)}`);
|
||||
if (typeof v !== "string" || !NAME_RE.test(v)) throw refuse(`${what} must be a seat name (lowercase letters, digits, hyphens)`);
|
||||
return v;
|
||||
}
|
||||
|
||||
function checkId(v, what = "row id") {
|
||||
if (!Number.isInteger(v) || v < 1 || v > 1_000_000) throw refuse(`${what} must be a positive integer: ${JSON.stringify(v)}`);
|
||||
if (!Number.isInteger(v) || v < 1 || v > 1_000_000) throw refuse(`${what} must be a positive integer`);
|
||||
return v;
|
||||
}
|
||||
|
||||
@@ -148,14 +148,14 @@ function checkTime(v, what, { unknown = false, date = false } = {}) {
|
||||
// as March 2).
|
||||
const t = Date.parse(iso ? v : `${v}T00:00:00.000Z`);
|
||||
if (Number.isFinite(t) && new Date(t).toISOString().slice(0, v.length) === v) return v;
|
||||
throw refuse(`${what} is not a calendar ${iso ? "time" : "date"}: ${JSON.stringify(v)}`);
|
||||
throw refuse(`${what} is not a calendar ${iso ? "time" : "date"}`);
|
||||
}
|
||||
throw refuse(`${what} must be an ISO time${date ? " or date" : ""}${unknown ? " or \"unknown\"" : ""}: ${JSON.stringify(v)}`);
|
||||
throw refuse(`${what} must be an ISO time${date ? " or date" : ""}${unknown ? " or \"unknown\"" : ""}`);
|
||||
}
|
||||
|
||||
export function checkRepoPath(v, what) {
|
||||
if (typeof v !== "string" || !PATH_RE.test(v) || v.split("/").some((s) => s === "." || s === "..")) {
|
||||
throw refuse(`${what} must be a repo-relative path: ${JSON.stringify(v)}`);
|
||||
throw refuse(`${what} must be a repo-relative path`);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
@@ -163,7 +163,7 @@ export function checkRepoPath(v, what) {
|
||||
export function parseBriefSpec(spec) {
|
||||
if (typeof spec !== "string") throw refuse("brief must be PATH#ANCHOR");
|
||||
const i = spec.indexOf("#");
|
||||
if (i < 0) throw refuse(`brief must be PATH#ANCHOR, naming the heading: ${JSON.stringify(spec)}`);
|
||||
if (i < 0) throw refuse(`brief must be PATH#ANCHOR, naming the heading`);
|
||||
const path = checkRepoPath(spec.slice(0, i), "brief path");
|
||||
const anchor = checkCellText(spec.slice(i + 1), "brief anchor", { max: 200 });
|
||||
return { path, anchor };
|
||||
@@ -197,9 +197,9 @@ export function parseManifest(text) {
|
||||
const lines = text.slice(0, -1).split("\n");
|
||||
for (const line of lines) {
|
||||
const m = /^([0-9a-f]{64}) {2}(.+)$/.exec(line);
|
||||
if (!m) throw refuse(`candidate manifest line is not "<sha256> <path>": ${JSON.stringify(line)}`);
|
||||
if (!m) throw refuse(`candidate manifest line is not "<sha256> <path>"`);
|
||||
checkRepoPath(m[2], "candidate manifest path");
|
||||
if (seen.has(m[2])) throw refuse(`candidate manifest names ${m[2]} twice`);
|
||||
if (seen.has(m[2])) throw refuse(`candidate manifest names a path twice`);
|
||||
seen.add(m[2]);
|
||||
}
|
||||
return lines.length;
|
||||
@@ -222,7 +222,7 @@ export function attemptOf(row, op) {
|
||||
}
|
||||
|
||||
function checkCommentId(v, what = "comment id") {
|
||||
if (!Number.isSafeInteger(v) || v < 1) throw refuse(`${what} must be a positive integer: ${JSON.stringify(v)}`);
|
||||
if (!Number.isSafeInteger(v) || v < 1) throw refuse(`${what} must be a positive integer`);
|
||||
return v;
|
||||
}
|
||||
|
||||
@@ -250,7 +250,7 @@ function checkAttempt(a) {
|
||||
checkCallerOpId(a.op, "review attempt op");
|
||||
checkName(a.by, "review attempt by");
|
||||
checkTime(a.at, "review attempt at");
|
||||
if (!ATTEMPT_STATES.includes(a.state)) throw refuse(`review attempt state ${JSON.stringify(a.state)} is not one of ${ATTEMPT_STATES.join(", ")}`);
|
||||
if (!ATTEMPT_STATES.includes(a.state)) throw refuse(`review attempt state is not one of ${ATTEMPT_STATES.join(", ")}`);
|
||||
if (a.comment !== null) checkCommentId(a.comment);
|
||||
if ((a.state === "posted") !== (a.comment !== null)) throw refuse("a review attempt carries a comment id exactly when it is posted");
|
||||
if (a.transport !== null) checkTransport(a.transport);
|
||||
@@ -311,7 +311,7 @@ function checkRound(v, n) {
|
||||
}
|
||||
|
||||
export function validateRow(row) {
|
||||
keysExactly(row, ROW_KEYS, `row ${row?.id}`);
|
||||
keysExactly(row, ROW_KEYS, `row`);
|
||||
const w = `row ${row.id}`;
|
||||
checkId(row.id);
|
||||
checkCellText(row.piece, `${w} piece`, { max: 300 });
|
||||
@@ -319,7 +319,7 @@ export function validateRow(row) {
|
||||
checkIssues(row.issues, `${w} issues`);
|
||||
checkIssues(row.closes, `${w} closes`);
|
||||
for (const n of row.closes) if (!row.issues.includes(n)) throw refuse(`${w} closes #${n}, which is not in its issues`);
|
||||
if (!STATES.includes(row.state)) throw refuse(`${w} state ${JSON.stringify(row.state)} is not a state`);
|
||||
if (!STATES.includes(row.state)) throw refuse(`${w} state is not a state (expected one of ${STATES.join(", ")})`);
|
||||
if (row.state === "blocked") {
|
||||
if (!NON_TERMINAL.has(row.previousState)) throw refuse(`${w} is blocked and needs the non-terminal state it returns to`);
|
||||
checkCellText(row.blockedReason, `${w} blockedReason`);
|
||||
@@ -346,7 +346,7 @@ export function validateRow(row) {
|
||||
}
|
||||
if (row.claim !== null) {
|
||||
keysExactly(row.claim, ["seat", "op"], `${w} claim`);
|
||||
if (row.claim.seat !== row.owner) throw refuse(`${w} claim seat ${row.claim.seat} is not the owner ${row.owner}`);
|
||||
if (row.claim.seat !== row.owner) throw refuse(`${w} claim seat is not the owner`);
|
||||
checkCallerOpId(row.claim.op, `${w} claim op`);
|
||||
const s = row.state === "blocked" ? row.previousState : row.state;
|
||||
if (!CLAIM_KEPT.has(s)) throw refuse(`${w} is claimed but ${row.state}`);
|
||||
@@ -398,7 +398,7 @@ function nullable(v, fn) {
|
||||
|
||||
function sortedUnique(list, cmp) {
|
||||
const out = [...list].sort(cmp);
|
||||
for (let i = 1; i < out.length; i++) if (cmp(out[i], out[i - 1]) === 0) throw refuse(`repeated value ${JSON.stringify(out[i])}`);
|
||||
for (let i = 1; i < out.length; i++) if (cmp(out[i], out[i - 1]) === 0) throw refuse(`repeated value in list`);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -463,7 +463,7 @@ export function canonArgs(verb, a) {
|
||||
};
|
||||
}
|
||||
case "move":
|
||||
if (!STATES.includes(a.to)) throw refuse(`unknown state ${JSON.stringify(a.to)}; states: ${STATES.join(", ")}`);
|
||||
if (!STATES.includes(a.to)) throw refuse(`unknown state; states: ${STATES.join(", ")}`);
|
||||
return {
|
||||
id: checkId(a.id),
|
||||
to: a.to,
|
||||
@@ -503,7 +503,7 @@ export function canonArgs(verb, a) {
|
||||
if (a.verdict !== "approve" && a.verdict !== "changes") throw refuse("--verdict must be approve or changes");
|
||||
return { id: checkId(a.id), verdict: a.verdict, comment: checkCommentId(a.comment), candidate: checkDigest(a.candidate, "candidate") };
|
||||
default:
|
||||
throw refuse(`unknown verb ${JSON.stringify(verb)}`);
|
||||
throw refuse(`unknown verb; expected a queue operation`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,8 +517,8 @@ const isPriv = (by) => PRIVILEGED.has(by);
|
||||
|
||||
function ownerOrPriv(row, by, doing) {
|
||||
if (by === row.owner || isPriv(by)) return;
|
||||
const claimed = row.claim !== null ? `row ${row.id} is claimed by ${row.claim.seat}; ` : "";
|
||||
throw refuse(`${claimed}only the owner (${row.owner}) or a privileged actor may ${doing} row ${row.id}`);
|
||||
const claimed = row.claim !== null ? `row ${row.id} is claimed; ` : "";
|
||||
throw refuse(`${claimed}only the owner or a privileged actor may ${doing} row ${row.id}`);
|
||||
}
|
||||
|
||||
function requirePriv(by, doing) {
|
||||
@@ -580,7 +580,7 @@ function unresolvedAttempts(row) {
|
||||
|
||||
function refuseUnresolved(row, doing) {
|
||||
const open = unresolvedAttempts(row);
|
||||
if (open.length) throw refuse(`row ${row.id} has an unresolved review request: ${open.join(", ")}; resolve or abandon it before ${doing}`);
|
||||
if (open.length) throw refuse(`row ${row.id} has an unresolved review request; resolve or abandon it before ${doing}`);
|
||||
}
|
||||
|
||||
// A comment round leaves in-review, for done or for Jason's gate, only on
|
||||
@@ -589,7 +589,7 @@ function requireApprovals(row, cur, to) {
|
||||
if (row.reviewers.length === 0) throw refuse(`row ${row.id} lists no reviewers now; a privileged actor sets them before it ${to === "done" ? "closes" : `moves to ${to}`}`);
|
||||
const approved = new Set(cur.receipts.filter((r) => r.verdict === "approve").map((r) => r.reviewer));
|
||||
const missing = row.reviewers.filter((s) => !approved.has(s));
|
||||
if (missing.length) throw refuse(`row ${row.id} round ${cur.n} has no approval recorded by ${missing.join(", ")}`);
|
||||
if (missing.length) throw refuse(`row ${row.id} round ${cur.n} has no approval recorded by every listed reviewer`);
|
||||
}
|
||||
|
||||
// The request round an attempt belongs to, and the attempt.
|
||||
@@ -599,7 +599,7 @@ function findAttempt(row, op) {
|
||||
const i = r.attempts.findIndex((x) => x.op === op);
|
||||
if (i >= 0) return { round: r, index: i, attempt: r.attempts[i] };
|
||||
}
|
||||
throw refuse(`row ${row.id} has no review request ${op}`);
|
||||
throw refuse(`row ${row.id} has no review request matching the attempt op`);
|
||||
}
|
||||
|
||||
// A copy of `row` with one attempt of one round replaced.
|
||||
@@ -632,20 +632,20 @@ function outcomeState(attempt, t) {
|
||||
return t.outcome === "posted" && t.comment === attempt.comment ? "posted" : "conflict";
|
||||
}
|
||||
if (attempt.state === "abandoned") return t.outcome === "posted" ? "conflict" : "abandoned";
|
||||
throw refuse(`attempt ${attempt.op} is ${attempt.state}; it already has its outcome`);
|
||||
throw refuse(`attempt is ${attempt.state}; it already has its outcome`);
|
||||
}
|
||||
|
||||
function applyReview(rows, row, entry) {
|
||||
const a = entry.args;
|
||||
const by = entry.by;
|
||||
if (!row.review && entry.verb !== "review-request" && entry.verb !== "review-record") throw refuse(`row ${row.id} has no review request ${a.attempt}`);
|
||||
if (!row.review && entry.verb !== "review-request" && entry.verb !== "review-record") throw refuse(`row ${row.id} has no review request matching the attempt op`);
|
||||
switch (entry.verb) {
|
||||
case "review-request": {
|
||||
ownerOrPriv(row, by, "request review of");
|
||||
const cur = currentCommentRound(row, "review request");
|
||||
const live = cur.attempts.filter((x) => x.state !== "failed" && x.state !== "abandoned");
|
||||
refuseUnresolved(row, "a new request");
|
||||
if (live.length) throw refuse(`row ${row.id} round ${cur.n} already has a posted request (${live[0].op}, comment ${live[0].comment})`);
|
||||
if (live.length) throw refuse(`row ${row.id} round ${cur.n} already has a posted request`);
|
||||
const rounds = row.review.rounds.map((r) => (r.n === cur.n ? { ...r, attempts: [...r.attempts, newAttempt(entry)] } : r));
|
||||
return {
|
||||
row: { ...row, review: { rounds } },
|
||||
@@ -654,11 +654,11 @@ function applyReview(rows, row, entry) {
|
||||
};
|
||||
}
|
||||
case "review-outcome": {
|
||||
if (entry.op !== `${a.attempt}.outcome`) throw refuse(`an outcome's op is its attempt's op plus .outcome (${a.attempt}.outcome)`);
|
||||
if (entry.op !== `${a.attempt}.outcome`) throw refuse(`an outcome's op is its attempt's op plus .outcome`);
|
||||
const found = findAttempt(row, a.attempt);
|
||||
const att = found.attempt;
|
||||
if (by !== att.by) throw refuse(`only ${att.by}, who made request ${att.op}, records its outcome`);
|
||||
if (att.transport !== null) throw refuse(`request ${att.op} already has its outcome`);
|
||||
if (by !== att.by) throw refuse(`only the actor who made the request records its outcome`);
|
||||
if (att.transport !== null) throw refuse(`request already has its outcome`);
|
||||
const t = { outcome: a.outcome, status: a.status, comment: a.comment, detail: a.detail };
|
||||
const state = outcomeState(att, t);
|
||||
const comment = state === "posted" ? (att.comment ?? t.comment) : null;
|
||||
@@ -678,7 +678,7 @@ function applyReview(rows, row, entry) {
|
||||
else requirePriv(by, "abandon a review request");
|
||||
const found = findAttempt(row, a.attempt);
|
||||
const att = found.attempt;
|
||||
if (!UNRESOLVED.has(att.state)) throw refuse(`request ${att.op} is ${att.state}; only a requesting, uncertain or conflict request can be ${resolve ? "resolved" : "abandoned"}`);
|
||||
if (!UNRESOLVED.has(att.state)) throw refuse(`request is ${att.state}; only a requesting, uncertain or conflict request can be ${resolve ? "resolved" : "abandoned"}`);
|
||||
const res = { verb: resolve ? "resolve" : "abandon", op: entry.op, by, at: entry.at, comment: resolve ? a.comment : null, reason: resolve ? null : a.reason };
|
||||
const state = resolve ? "posted" : "abandoned";
|
||||
const next = withAttempt(row, found, { ...att, state, comment: resolve ? a.comment : null, resolutions: [...att.resolutions, res] }, resolve ? {} : { duplicateRisk: true });
|
||||
@@ -689,11 +689,11 @@ function applyReview(rows, row, entry) {
|
||||
};
|
||||
}
|
||||
case "review-record": {
|
||||
if (!row.reviewers.includes(by) || by === row.owner) throw refuse(`only a listed reviewer other than the owner may record a verdict on row ${row.id} (reviewers: ${fmt(row.reviewers)})`);
|
||||
if (!row.reviewers.includes(by) || by === row.owner) throw refuse(`only a listed reviewer other than the owner may record a verdict on row ${row.id}`);
|
||||
if (!row.review) throw refuse(`row ${row.id} has no review round`);
|
||||
const cur = currentCommentRound(row, "review record");
|
||||
if (a.candidate !== cur.candidate.digest) throw refuse(`candidate ${a.candidate} is not round ${cur.n}'s candidate ${cur.candidate.digest}`);
|
||||
if (cur.receipts.some((r) => r.reviewer === by)) throw refuse(`${by} already recorded a verdict for row ${row.id} round ${cur.n}`);
|
||||
if (a.candidate !== cur.candidate.digest) throw refuse(`candidate is not round ${cur.n}'s candidate`);
|
||||
if (cur.receipts.some((r) => r.reviewer === by)) throw refuse(`reviewer already recorded a verdict for row ${row.id} round ${cur.n}`);
|
||||
const rec = { reviewer: by, op: entry.op, at: entry.at, verdict: a.verdict, comment: a.comment, candidate: a.candidate };
|
||||
const rounds = row.review.rounds.map((r) => (r.n === cur.n ? { ...r, receipts: [...r.receipts, rec] } : r));
|
||||
return {
|
||||
@@ -703,7 +703,7 @@ function applyReview(rows, row, entry) {
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw refuse(`unknown verb ${entry.verb}`);
|
||||
throw refuse(`unknown verb; expected a queue operation`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -759,14 +759,14 @@ function applyMove(rows, row, entry, resolved) {
|
||||
} else if (from === "queued" && to === "briefed") {
|
||||
requirePriv(by, "accept a brief (queued→briefed)");
|
||||
} else if (from === "briefed" && to === "in-progress") {
|
||||
if (by !== row.owner) throw refuse(`only the owner (${row.owner}) may start row ${row.id}`);
|
||||
if (by !== row.owner) throw refuse(`only the owner may start row ${row.id}`);
|
||||
const missing = afterSatisfied(rows, row);
|
||||
if (missing.length) throw refuse(`row ${row.id} waits on ${missing.join(", ")}`);
|
||||
next.claim = { seat: row.owner, op: entry.op };
|
||||
} else if (from === "in-progress" && to === "briefed") {
|
||||
throw refuse(`use \`queue release ${row.id}\` to give up a claim`);
|
||||
} else if (from === "in-progress" && to === "in-review") {
|
||||
if (row.claim === null || by !== row.claim.seat) throw refuse(`only the claimant (${row.claim?.seat ?? "nobody"}) may request review of row ${row.id}`);
|
||||
if (row.claim === null || by !== row.claim.seat) throw refuse(`only the claimant may request review of row ${row.id}`);
|
||||
if (candidate === null) throw refuse("in-progress→in-review needs --candidate <commit|manifest>");
|
||||
revIssue = reviewIssue(row, issue);
|
||||
cand = checkCandidate(resolved.candidate);
|
||||
@@ -797,7 +797,7 @@ function applyMove(rows, row, entry, resolved) {
|
||||
next.claim = null;
|
||||
} else if (from === "in-review" && to === "done") {
|
||||
if (row.gateOwner === "jason") throw refuse(`row ${row.id}'s gate is Jason's; it goes through waiting-on-jason`);
|
||||
if (by !== row.gateOwner && !isPriv(by)) throw refuse(`only the gate owner (${row.gateOwner}) or a privileged actor may close row ${row.id}`);
|
||||
if (by !== row.gateOwner && !isPriv(by)) throw refuse(`only the gate owner or a privileged actor may close row ${row.id}`);
|
||||
refuseUnresolved(row, "closing it");
|
||||
const cur = row.review?.rounds.at(-1);
|
||||
if (cur?.request === "comment") {
|
||||
@@ -808,7 +808,7 @@ function applyMove(rows, row, entry, resolved) {
|
||||
const ev = parseReviewEvidence(evidence);
|
||||
if (!cur) throw refuse(`row ${row.id} has no review round to cite`);
|
||||
if (ev.round !== cur.n) throw refuse(`evidence names round ${ev.round}; row ${row.id} is in round ${cur.n}`);
|
||||
if (ev.candidate !== cur.candidate.digest) throw refuse(`evidence candidate ${ev.candidate} is not round ${cur.n}'s candidate ${cur.candidate.digest}`);
|
||||
if (ev.candidate !== cur.candidate.digest) throw refuse(`evidence candidate is not round ${cur.n}'s candidate`);
|
||||
}
|
||||
round = cur.n;
|
||||
next.claim = null;
|
||||
@@ -834,7 +834,7 @@ function applySet(rows, row, entry, resolved) {
|
||||
switch (field) {
|
||||
case "piece": case "gate": case "reviewers": case "issues":
|
||||
requirePriv(by, `change ${field}`);
|
||||
if (field === "reviewers" && value.includes(row.owner)) throw refuse(`row ${row.id} owner ${row.owner} can't be a listed reviewer`);
|
||||
if (field === "reviewers" && value.includes(row.owner)) throw refuse(`row ${row.id} owner can't be a listed reviewer`);
|
||||
next[field] = value;
|
||||
// N10: closes follows the issues only if nobody narrowed it. A logged
|
||||
// narrowing keeps its intersection with the new issues.
|
||||
@@ -860,7 +860,7 @@ function applySet(rows, row, entry, resolved) {
|
||||
requirePriv(by, "re-pin a brief");
|
||||
next.brief = checkBrief(resolved.brief);
|
||||
if (next.brief.path !== parseBriefSpec(value).path || next.brief.anchor !== parseBriefSpec(value).anchor) throw refuse("resolved brief does not match the brief argument");
|
||||
if (row.brief !== null && next.brief.blob === row.brief.blob && sameJson(next.brief, row.brief)) throw refuse(`row ${row.id} brief is already pinned to ${row.brief.blob}`);
|
||||
if (row.brief !== null && next.brief.blob === row.brief.blob && sameJson(next.brief, row.brief)) throw refuse(`row ${row.id} brief is already pinned to that blob`);
|
||||
break;
|
||||
case "required":
|
||||
if (value) {
|
||||
@@ -877,7 +877,7 @@ function applySet(rows, row, entry, resolved) {
|
||||
throw refuse(`set cannot change ${field}`);
|
||||
}
|
||||
if (sameJson(next[key], row[key]) && (field !== "issues" || sameJson(next.closes, row.closes))) {
|
||||
throw refuse(`row ${row.id} ${key} is already ${fmt(row[key])}`);
|
||||
throw refuse(`row ${row.id} ${key} is already set to that value`);
|
||||
}
|
||||
const result = { row: row.id, field: key, from: row[key], to: next[key] };
|
||||
if (field === "issues") result.closes = { from: row.closes, to: next.closes, narrowed: !sameJson(row.closes, row.issues) };
|
||||
@@ -908,7 +908,7 @@ export function applyEntry(state, entry, resolved) {
|
||||
const id = highWater + 1;
|
||||
const owner = a.owner ?? by;
|
||||
const reviewers = a.reviewers ?? [];
|
||||
if (reviewers.includes(owner)) throw refuse(`row ${id} owner ${owner} can't be a listed reviewer`);
|
||||
if (reviewers.includes(owner)) throw refuse(`row ${id} owner can't be a listed reviewer`);
|
||||
highWater = id;
|
||||
const required = a.required ?? false;
|
||||
const row = {
|
||||
@@ -947,7 +947,7 @@ export function applyEntry(state, entry, resolved) {
|
||||
case "release": {
|
||||
const row = getRow(rows, a.id);
|
||||
if (row.state !== "in-progress") throw refuse(`row ${row.id} is ${row.state}; release applies to in-progress rows`);
|
||||
if (!(row.claim && by === row.claim.seat) && !isPriv(by)) throw refuse(`only the claimant (${row.claim?.seat}) or a privileged actor may release row ${row.id}`);
|
||||
if (!(row.claim && by === row.claim.seat) && !isPriv(by)) throw refuse(`only the claimant or a privileged actor may release row ${row.id}`);
|
||||
rows.set(row.id, touch({ ...row, state: "briefed", claim: null }, entry));
|
||||
result = { row: row.id, from: "in-progress", to: "briefed", receipt: receipt(entry, rev, `row ${row.id} in-progress→briefed`) };
|
||||
break;
|
||||
@@ -956,8 +956,8 @@ export function applyEntry(state, entry, resolved) {
|
||||
const row = getRow(rows, a.id);
|
||||
requirePriv(by, "assign a row");
|
||||
if (row.state === "done") throw refuse(`row ${row.id} is done; done rows never change`);
|
||||
if (row.owner === a.seat) throw refuse(`row ${row.id} is already owned by ${a.seat}`);
|
||||
if (row.reviewers.includes(a.seat)) throw refuse(`can't assign row ${row.id} to ${a.seat}: ${a.seat} is one of its reviewers`);
|
||||
if (row.owner === a.seat) throw refuse(`row ${row.id} is already owned by that seat`);
|
||||
if (row.reviewers.includes(a.seat)) throw refuse(`can't assign row ${row.id}: seat is one of its reviewers`);
|
||||
const claim = row.claim ? { seat: a.seat, op: entry.op } : null;
|
||||
rows.set(row.id, touch({ ...row, owner: a.seat, claim }, entry));
|
||||
result = { row: row.id, field: "owner", from: row.owner, to: a.seat, receipt: receipt(entry, rev, `row ${row.id} owner: ${row.owner}→${a.seat}`) };
|
||||
@@ -966,7 +966,7 @@ export function applyEntry(state, entry, resolved) {
|
||||
case "note": {
|
||||
const row = getRow(rows, a.id);
|
||||
if (row.state === "done" || row.state === "parked") throw refuse(`row ${row.id} is ${row.state}; notes are closed`);
|
||||
if (by !== row.owner && !row.reviewers.includes(by) && !isPriv(by)) throw refuse(`only the owner (${row.owner}), a listed reviewer or a privileged actor may note row ${row.id}`);
|
||||
if (by !== row.owner && !row.reviewers.includes(by) && !isPriv(by)) throw refuse(`only the owner, a listed reviewer or a privileged actor may note row ${row.id}`);
|
||||
// On a blocked row the note is the reason (8.7: "update the reason
|
||||
// with note"); elsewhere it is the row's note, and "" clears it.
|
||||
const field = row.state === "blocked" ? "blockedReason" : "note";
|
||||
@@ -1003,7 +1003,7 @@ export function applyEntry(state, entry, resolved) {
|
||||
case "genesis":
|
||||
throw refuse("genesis is only log[0]");
|
||||
default:
|
||||
throw refuse(`unknown verb ${entry.verb}`);
|
||||
throw refuse(`unknown verb; expected a queue operation`);
|
||||
}
|
||||
validateRows([...rows.values()]);
|
||||
return { state: { rows, highWater, retired: state.retired, revision: rev }, result };
|
||||
@@ -1025,15 +1025,15 @@ export function parseMigrationMap(text) {
|
||||
const blocks = [...text.matchAll(/^```json queue-map\n([\s\S]*?)^```$/gm)];
|
||||
if (blocks.length !== 1) throw refuse("the migration map must hold exactly one ```json queue-map block");
|
||||
let map;
|
||||
try { map = JSON.parse(blocks[0][1]); } catch (err) { throw refuse(`the migration map's queue-map block is not JSON: ${err.message}`); }
|
||||
try { map = JSON.parse(blocks[0][1]); } catch { throw refuse(`the migration map's queue-map block is not JSON`); }
|
||||
keysExactly(map, ["rows", "retired", "highWater"], "migration map");
|
||||
if (!Array.isArray(map.rows)) throw refuse("migration map rows must be a list");
|
||||
map.rows.forEach((r) => {
|
||||
keysExactly(r, MAP_ROW_KEYS, `migration map row ${r?.id}`);
|
||||
if (r.brief !== null) keysExactly(r.brief, ["path", "anchor"], `migration map row ${r.id} brief`);
|
||||
keysExactly(r, MAP_ROW_KEYS, `migration map row`);
|
||||
if (r.brief !== null) keysExactly(r.brief, ["path", "anchor"], `migration map row brief`);
|
||||
// The rule add, set and assign apply. Here, not in checkGenesis, so a
|
||||
// log written before the rule still replays.
|
||||
if (Array.isArray(r.reviewers) && r.reviewers.includes(r.owner)) throw refuse(`migration map row ${r.id} owner ${r.owner} can't be a listed reviewer`);
|
||||
if (Array.isArray(r.reviewers) && r.reviewers.includes(r.owner)) throw refuse(`migration map row owner can't be a listed reviewer`);
|
||||
});
|
||||
if (!Array.isArray(map.retired)) throw refuse("migration map retired must be a list");
|
||||
map.retired.forEach((id) => checkId(id, "retired id"));
|
||||
@@ -1102,8 +1102,8 @@ export function rowsArray(state) {
|
||||
|
||||
function checkEntryShape(e, i) {
|
||||
keysExactly(e, ENTRY_KEYS, `log entry ${i}`);
|
||||
if (e.rev !== i) throw refuse(`log entry ${i} has rev ${e.rev}`);
|
||||
if (!VERBS.includes(e.verb)) throw refuse(`log entry ${i} verb ${JSON.stringify(e.verb)} is unknown`);
|
||||
if (e.rev !== i) throw refuse(`log entry ${i} has rev that must equal its index`);
|
||||
if (!VERBS.includes(e.verb)) throw refuse(`log entry ${i} verb is unknown`);
|
||||
if (e.verb === "review-outcome") {
|
||||
if (typeof e.op !== "string" || !LOG_OP_RE.test(e.op) || !isObj(e.args) || e.op !== `${e.args.attempt}.outcome`) throw refuse(`log entry ${i} op must be its attempt's op plus .outcome`);
|
||||
} else {
|
||||
@@ -1111,7 +1111,7 @@ function checkEntryShape(e, i) {
|
||||
}
|
||||
checkName(e.by, `log entry ${i} by`);
|
||||
checkTime(e.at, `log entry ${i} at`);
|
||||
if (!Number.isInteger(e.semantics) || e.semantics < 1 || e.semantics > SEMANTICS) throw refuse(`log entry ${i} semantics ${e.semantics} is not 1 to ${SEMANTICS}`);
|
||||
if (!Number.isInteger(e.semantics) || e.semantics < 1 || e.semantics > SEMANTICS) throw refuse(`log entry ${i} semantics is not 1 to ${SEMANTICS}`);
|
||||
if (REVIEW_VERBS.includes(e.verb) && e.semantics < 2) throw refuse(`log entry ${i} ${e.verb} needs semantics 2`);
|
||||
if (typeof e.viewSha !== "string" || !SHA256_RE.test(e.viewSha)) throw refuse(`log entry ${i} viewSha is not a SHA-256`);
|
||||
}
|
||||
@@ -1126,19 +1126,19 @@ export function loadDoc(bytes) {
|
||||
throw refuse("queue.json is not valid UTF-8");
|
||||
}
|
||||
let doc;
|
||||
try { doc = JSON.parse(text); } catch (err) { throw refuse(`queue.json is not JSON: ${err.message}`); }
|
||||
try { doc = JSON.parse(text); } catch { throw refuse(`queue.json is not JSON`); }
|
||||
keysExactly(doc, DOC_KEYS, "queue.json");
|
||||
if (serialize(doc) !== text) throw refuse("queue.json does not re-serialize byte for byte (a hand edit?)");
|
||||
if (doc.version !== VERSION) throw refuse(`queue.json version ${doc.version} is not ${VERSION}`);
|
||||
if (doc.version !== VERSION) throw refuse(`queue.json version is not ${VERSION}`);
|
||||
if (typeof doc.canonicalRoot !== "string" || !doc.canonicalRoot.startsWith("/")) throw refuse("canonicalRoot must be an absolute path");
|
||||
if (!Array.isArray(doc.rows) || !Array.isArray(doc.log) || doc.log.length === 0) throw refuse("queue.json needs rows and a log starting at genesis");
|
||||
doc.log.forEach(checkEntryShape);
|
||||
const ops = new Set();
|
||||
for (const e of doc.log) {
|
||||
if (ops.has(e.op)) throw refuse(`op id ${e.op} appears twice in the log`);
|
||||
if (ops.has(e.op)) throw refuse(`op id appears twice in the log`);
|
||||
ops.add(e.op);
|
||||
}
|
||||
if (doc.revision !== doc.log.length - 1) throw refuse(`revision ${doc.revision} is not the last rev ${doc.log.length - 1}`);
|
||||
if (doc.revision !== doc.log.length - 1) throw refuse(`revision is not the last rev ${doc.log.length - 1}`);
|
||||
const state = replay(doc.log);
|
||||
if (!sameJson(rowsArray(state), doc.rows)) throw refuse("rows do not equal the replay of the log (a hand edit?)");
|
||||
if (doc.canonicalRoot !== doc.log[0].args.root) throw refuse("canonicalRoot differs from the genesis root");
|
||||
@@ -1158,10 +1158,10 @@ export function replay(log) {
|
||||
try {
|
||||
out = applyEntry(state, e, resolvedFromResult(e.verb, e.args, e.result));
|
||||
} catch (err) {
|
||||
if (err instanceof QueueError) throw refuse(`log entry ${i} (${e.op}) does not replay: ${err.message}`);
|
||||
if (err instanceof QueueError) throw refuse(`log entry ${i} does not replay: ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
if (!sameJson(out.result, e.result)) throw refuse(`log entry ${i} (${e.op}) result differs from its replay`);
|
||||
if (!sameJson(out.result, e.result)) throw refuse(`log entry ${i} result differs from its replay`);
|
||||
state = out.state;
|
||||
if (e.viewSha !== sha256(render(rowsArray(state), i))) throw refuse(`log entry ${i} viewSha is not the render of its rows`);
|
||||
}
|
||||
|
||||
@@ -119,8 +119,8 @@ test("add: defaults for an ordinary seat, privileged extras, refusals", () => {
|
||||
refused(() => step(d, "add", { ...add, owner: "rocko" }, "dewey", { brief: brief() }), /only a privileged actor may set owner/);
|
||||
refused(() => step(d, "add", { ...add, reviewers: ["rocko"] }, "dewey", { brief: brief() }), /reviewers/);
|
||||
refused(() => step(d, "add", { ...add, required: true }, "dewey", { brief: brief() }), /required/);
|
||||
refused(() => step(d, "add", { ...add, owner: "rocko", reviewers: ["rocko"] }, "sage", { brief: brief() }), /row 12 owner rocko can't be a listed reviewer/);
|
||||
refused(() => step(d, "add", { ...add, reviewers: ["sage"] }, "sage", { brief: brief() }), /row 12 owner sage can't be a listed reviewer/);
|
||||
refused(() => step(d, "add", { ...add, owner: "rocko", reviewers: ["rocko"] }, "sage", { brief: brief() }), /row 12 owner can't be a listed reviewer/);
|
||||
refused(() => step(d, "add", { ...add, reviewers: ["sage"] }, "sage", { brief: brief() }), /row 12 owner can't be a listed reviewer/);
|
||||
const d2 = step(d, "add", { ...add, owner: "rocko", gateOwner: "filbert", after: [{ id: 9, when: "done" }], reviewers: ["dewey"], required: true }, "sage", { brief: brief() });
|
||||
const r2 = row(d2, 12);
|
||||
assert.deepEqual([r2.owner, r2.gateOwner, r2.required, r2.after], ["rocko", "filbert", true, [{ id: 9, when: "done" }]]);
|
||||
@@ -134,7 +134,7 @@ test("matrix: queued→briefed privileged; briefed→in-progress owner with afte
|
||||
let d = step(genesisDoc(), "add", { piece: "N", gate: "g", brief: "docs/plans/brief-b.md#Queue" }, "dewey", { brief: brief() });
|
||||
refused(() => step(d, "move", mv(12, "briefed"), "dewey"), /privileged/);
|
||||
d = step(d, "move", mv(12, "briefed"), "sage");
|
||||
refused(() => step(d, "move", mv(12, "in-progress"), "sage"), /only the owner \(dewey\) may start/);
|
||||
refused(() => step(d, "move", mv(12, "in-progress"), "sage"), /only the owner may start/);
|
||||
d = step(d, "move", mv(12, "in-progress"), "dewey");
|
||||
assert.deepEqual(row(d, 12).claim.seat, "dewey");
|
||||
refused(() => step(d, "move", mv(12, "in-progress"), "dewey"), /not a transition/);
|
||||
@@ -158,7 +158,7 @@ test("matrix: release, review round, changes requested and waiting-on-jason", ()
|
||||
assert.deepEqual(Object.keys(rv), ["rounds"]);
|
||||
assert.deepEqual(rv.rounds.map((r) => [r.n, r.issue, r.request, r.candidate.digest]), [[1, 1508, "none", CAND.digest]]);
|
||||
assert.match(d.log.at(-1).result.receipt, /in-progress→in-review round 1 on #1508$/);
|
||||
refused(() => step(d, "move", mv(9, "in-progress"), "dewey"), /claimed by darkwing/);
|
||||
refused(() => step(d, "move", mv(9, "in-progress"), "dewey"), /claimed/);
|
||||
d = step(d, "move", mv(9, "in-progress"), "darkwing");
|
||||
assert.equal(row(d, 9).claim.seat, "darkwing");
|
||||
d = step(d, "move", mv(9, "in-review", { candidate: "y" }), "darkwing", { candidate: { kind: "commit", digest: BLOB2, text: null } });
|
||||
@@ -182,7 +182,7 @@ test("matrix J5: in-review→done by the gate owner with evidence naming the cur
|
||||
refused(() => step(d, "move", mv(9, "done", { evidence: `comment=4242,candidate=${CAND.digest}` }), "filbert"), /round=<n>/);
|
||||
refused(() => step(d, "move", mv(9, "done", { evidence: `comment=1,round=1,candidate=${"d".repeat(64)}` }), "filbert"), /is not round 1's candidate/);
|
||||
refused(() => step(d, "move", mv(9, "done", { evidence: `comment=4242,round=2,candidate=${CAND.digest}` }), "filbert"), /evidence names round 2; row 9 is in round 1/);
|
||||
refused(() => step(d, "move", mv(9, "done", { evidence: ev }), "rocko"), /only the gate owner \(filbert\)/);
|
||||
refused(() => step(d, "move", mv(9, "done", { evidence: ev }), "rocko"), /only the gate owner/);
|
||||
const done = step(d, "move", mv(9, "done", { evidence: ev }), "filbert");
|
||||
assert.equal(row(done, 9).state, "done");
|
||||
step(d, "move", mv(9, "done", { evidence: ev }), "sage");
|
||||
@@ -357,7 +357,7 @@ test("matrix R1: every state × target × actor class matches 8.7, gate owner ja
|
||||
test("matrix: blocked keeps the claim and returns only to previousState", () => {
|
||||
let d = row9Started();
|
||||
refused(() => step(d, "move", mv(9, "blocked"), "darkwing"), /needs --reason/);
|
||||
refused(() => step(d, "move", mv(9, "blocked", { reason: "x" }), "rocko"), /claimed by darkwing/);
|
||||
refused(() => step(d, "move", mv(9, "blocked", { reason: "x" }), "rocko"), /claimed/);
|
||||
d = step(d, "move", mv(9, "blocked", { reason: "waiting on git" }), "darkwing");
|
||||
const r = row(d, 9);
|
||||
assert.deepEqual([r.state, r.previousState, r.blockedReason, r.claim.seat], ["blocked", "in-progress", "waiting on git", "darkwing"]);
|
||||
@@ -391,8 +391,8 @@ test("field edits: who may change what", () => {
|
||||
const set = (id, field, value, reason = null) => ({ id, field, value, reason });
|
||||
refused(() => step(d, "set", set(11, "piece", "x"), "dewey"), /privileged/);
|
||||
assert.match(step(d, "set", set(11, "piece", "Renamed"), "sage").log.at(-1).result.receipt, /row 11 piece: Brief template→Renamed$/);
|
||||
refused(() => step(d, "set", set(9, "reviewers", ["darkwing"]), "sage"), /row 9 owner darkwing can't be a listed reviewer/);
|
||||
refused(() => step(d, "set", set(9, "reviewers", ["filbert", "darkwing"]), "sage"), /row 9 owner darkwing can't be a listed reviewer/);
|
||||
refused(() => step(d, "set", set(9, "reviewers", ["darkwing"]), "sage"), /row 9 owner can't be a listed reviewer/);
|
||||
refused(() => step(d, "set", set(9, "reviewers", ["filbert", "darkwing"]), "sage"), /row 9 owner can't be a listed reviewer/);
|
||||
assert.deepEqual(row(step(d, "set", set(9, "reviewers", ["dewey"]), "sage"), 9).reviewers, ["dewey"]);
|
||||
refused(() => step(d, "set", set(9, "after", []), "sage"), /only jason may change after on a required row/);
|
||||
step(d, "set", set(9, "after", []), "jason");
|
||||
@@ -508,7 +508,7 @@ test("every accepted text renders to nine cells on every row (N8)", () => {
|
||||
|
||||
test("genesis: the map refuses an owner among its row's reviewers; replay doesn't (2026-10-04)", () => {
|
||||
const self = MAP_ROWS.map((m) => (m.id === 9 ? { ...m, reviewers: ["darkwing", "filbert"] } : m));
|
||||
refused(() => parseMigrationMap(mapText(self)), /migration map row 9 owner darkwing can't be a listed reviewer/);
|
||||
refused(() => parseMigrationMap(mapText(self)), /migration map row owner can't be a listed reviewer/);
|
||||
// A log from before the rule still loads: the check is on the map, not in replay.
|
||||
const old = genesisDoc(self, "genesis-op-0001", { rows: self, retired: [7], highWater: 11 });
|
||||
assert.deepEqual(row(loadDoc(Buffer.from(serialize(old))).doc, 9).reviewers, ["darkwing", "filbert"]);
|
||||
@@ -522,8 +522,8 @@ test("times and dates must be calendar values, not just the shape (2026-10-04)",
|
||||
validateRow({ ...r, createdAt: v });
|
||||
}
|
||||
for (const v of ["2026-13-01", "2026-00-10", "2026-01-32", "2026-02-30", "2027-02-29", "2026-04-31"]) {
|
||||
refused(() => validateRow({ ...r, requiredSince: v }), new RegExp(`row 9 requiredSince is not a calendar date: "${v}"`));
|
||||
refused(() => validateRow({ ...r, createdAt: v }), new RegExp(`row 9 createdAt is not a calendar date: "${v}"`));
|
||||
refused(() => validateRow({ ...r, requiredSince: v }), /row 9 requiredSince is not a calendar date/);
|
||||
refused(() => validateRow({ ...r, createdAt: v }), /row 9 createdAt is not a calendar date/);
|
||||
}
|
||||
// ISO times: the same days, and times past the clock. V8 reads 2026-02-30
|
||||
// as March 2 and 24:00 as the next day; only the round trip catches those.
|
||||
@@ -533,15 +533,15 @@ test("times and dates must be calendar values, not just the shape (2026-10-04)",
|
||||
}
|
||||
const T = "T12:00:00.000Z";
|
||||
for (const v of [`2026-13-01${T}`, `2026-01-32${T}`, `2026-02-30${T}`, `2027-02-29${T}`, "2026-01-01T24:00:00.000Z", "2026-01-01T23:60:00.000Z", "2026-01-01T23:59:60.000Z"]) {
|
||||
refused(() => validateRow({ ...r, updatedAt: v }), new RegExp(`row 9 updatedAt is not a calendar time: "${v}"`));
|
||||
refused(() => validateRow({ ...r, requiredSince: v }), new RegExp(`row 9 requiredSince is not a calendar time: "${v}"`));
|
||||
refused(() => validateRow({ ...r, updatedAt: v }), /row 9 updatedAt is not a calendar time/);
|
||||
refused(() => validateRow({ ...r, requiredSince: v }), /row 9 requiredSince is not a calendar time/);
|
||||
}
|
||||
// A shape error keeps its own message.
|
||||
refused(() => validateRow({ ...r, updatedAt: "2026-01-01" }), /row 9 updatedAt must be an ISO time: "2026-01-01"/);
|
||||
refused(() => validateRow({ ...r, updatedAt: "2026-01-01" }), /row 9 updatedAt must be an ISO time/);
|
||||
// A log entry's time goes through the same check on replay.
|
||||
const d = step(genesisDoc(), "note", { id: 9, text: "x" }, "darkwing");
|
||||
d.log[1].at = `2026-02-30${T}`;
|
||||
refused(() => loadDoc(Buffer.from(serialize(d))), /log entry 1 at is not a calendar time: "2026-02-30T12:00:00.000Z"/);
|
||||
refused(() => loadDoc(Buffer.from(serialize(d))), /log entry 1 at is not a calendar time/);
|
||||
});
|
||||
|
||||
test("replay holds every op id to the caller's rule (N11)", () => {
|
||||
@@ -550,10 +550,10 @@ test("replay holds every op id to the caller's rule (N11)", () => {
|
||||
// 72 characters replay; 73 to 80 no longer do.
|
||||
load(step(d, "note", { id: 9, text: "x" }, "darkwing", {}, "a".repeat(72)));
|
||||
for (const n of [73, 80]) {
|
||||
refused(() => load(step(d, "note", { id: 9, text: "x" }, "darkwing", {}, "a".repeat(n))), /log entry 1 op .* must match/);
|
||||
refused(() => load(step(d, "note", { id: 9, text: "x" }, "darkwing", {}, "a".repeat(n))), /log entry 1 op must match/);
|
||||
}
|
||||
// .outcome is reserved on every verb, accept-history included.
|
||||
refused(() => load(step(d, "accept-history", { reason: "r" }, "sage", { oldWitness: null }, "accept-history-1.outcome")), /log entry 1 op .* ends in \.outcome/);
|
||||
refused(() => load(step(d, "accept-history", { reason: "r" }, "sage", { oldWitness: null }, "accept-history-1.outcome")), /log entry 1 op ends in \.outcome/);
|
||||
refused(() => load(step(d, "note", { id: 9, text: "x" }, "darkwing", {}, "note-row9.outcome")), /\.outcome, which is reserved/);
|
||||
// The genesis op is checked too.
|
||||
load(genesisDoc(MAP_ROWS, "genesis-ok-0001"));
|
||||
@@ -586,11 +586,11 @@ test("note: owner, listed reviewer or privileged; empty clears", () => {
|
||||
test("assign moves the claim with the owner; done clears it", () => {
|
||||
let d = row9Started();
|
||||
refused(() => step(d, "assign", { id: 9, seat: "dewey" }, "darkwing"), /privileged/);
|
||||
refused(() => step(d, "assign", { id: 9, seat: "filbert" }, "sage"), /can't assign row 9 to filbert: filbert is one of its reviewers/);
|
||||
refused(() => step(d, "assign", { id: 9, seat: "filbert" }, "sage"), /can't assign row 9: seat is one of its reviewers/);
|
||||
d = step(d, "assign", { id: 9, seat: "dewey" }, "sage", {}, "assign-row-9-dewey");
|
||||
assert.deepEqual(row(d, 9).claim, { seat: "dewey", op: "assign-row-9-dewey" });
|
||||
assert.match(d.log.at(-1).result.receipt, /owner: darkwing→dewey$/);
|
||||
refused(() => step(d, "move", mv(9, "in-review", { candidate: "x" }), "darkwing", { candidate: CAND }), /only the claimant \(dewey\)/);
|
||||
refused(() => step(d, "move", mv(9, "in-review", { candidate: "x" }), "darkwing", { candidate: CAND }), /only the claimant/);
|
||||
const unclaimed = step(genesisDoc(), "assign", { id: 11, seat: "rocko" }, "sage");
|
||||
assert.equal(row(unclaimed, 11).claim, null);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Scratch repositories for the queue tests. Every test works under
|
||||
// os.tmpdir(): a fresh repository holding a copy of packages/queue/src and
|
||||
// the two discord helpers it imports, so the code-under-root check (8.3)
|
||||
// the discord helpers and safe JSON parser they import, so the code-under-root check (8.3)
|
||||
// passes there and nothing touches this checkout's .git.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { cpSync, mkdirSync, mkdtempSync, realpathSync, rmSync, statfsSync, writeFileSync } from "node:fs";
|
||||
@@ -91,6 +91,9 @@ export function scratchRepo(t, { map = mapText(), commitMap = true, name = "repo
|
||||
cpSync(PKG_SRC, join(root, "packages/queue/src"), { recursive: true });
|
||||
mkdirSync(join(root, "packages/discord/src"), { recursive: true });
|
||||
for (const f of ["journal.mjs", "errors.mjs"]) cpSync(join(DISCORD_SRC, f), join(root, "packages/discord/src", f));
|
||||
mkdirSync(join(root, "packages/organization/src"), { recursive: true });
|
||||
for (const f of ["util.mjs", "errors.mjs", "vocabulary.mjs"])
|
||||
cpSync(join(DISCORD_SRC, "../../organization/src", f), join(root, "packages/organization/src", f));
|
||||
for (const s of ["darkwing", "dewey", "filbert", "rocko", "sage"]) {
|
||||
mkdirSync(join(root, "agents", s, "work"), { recursive: true });
|
||||
writeFileSync(join(root, "agents", s, ".keep"), "");
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
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 { checkCallerOpId, checkName, checkRepoPath, parseManifest, validateRow, loadDoc, serialize } from '../src/queue.mjs';
|
||||
import { QueueError } from '../src/errors.mjs';
|
||||
for (const [field, fn] of [
|
||||
['op', () => checkCallerOpId(secret + '/', 'op')],
|
||||
['op', () => checkCallerOpId(secret.toLowerCase() + '.outcome', 'op')],
|
||||
['owner', () => checkName(secret, 'owner')],
|
||||
['path', () => checkRepoPath(secret + '/', 'path')],
|
||||
['manifest', () => parseManifest(secret + '\n')],
|
||||
['row', () => validateRow({ id: secret })],
|
||||
['version', () => loadDoc(Buffer.from(serialize({ version: secret, canonicalRoot: '/x', revision: 0, rows: [], log: [] })))],
|
||||
]) test(`queue ${field} semantic refusal omits the value`, () => assert.throws(fn, safe(field, QueueError, { code: 2 }, secret.toLowerCase())));
|
||||
import { checkTransport, parseBriefSpec, genesisRows, parseMigrationMap, render, sha256, genesisReceipt } from '../src/queue.mjs';
|
||||
import { MAP_ROWS, mapText } from './helpers.mjs';
|
||||
const at = '2026-11-10T00:00:00.000Z';
|
||||
function doc() {
|
||||
const map = parseMigrationMap(mapText()), rows = genesisRows(map, new Map(map.rows.filter(r => r.brief).map(r => [r.id, 'a'.repeat(40)])), 'genesis-op-0001', at, 'sage');
|
||||
const entry = { rev: 0, op: 'genesis-op-0001', verb: 'genesis', args: { root: '/x', branch: 'refactor', map: 'docs/map.md' }, by: 'sage', at, semantics: 1,
|
||||
result: { mapBlob: 'b'.repeat(40), highWater: map.highWater, retired: map.retired, rows, legacyView: 'legacy\n', receipt: genesisReceipt('genesis-op-0001', rows.length) }, viewSha: sha256(render(rows, 0)) };
|
||||
return { version: 1, canonicalRoot: '/x', revision: 0, rows, log: [entry] };
|
||||
}
|
||||
for (const [field, edit, value = secret] of [
|
||||
['id', r => { r.id = secret; }], ['state', r => { r.state = secret; }],
|
||||
['createdAt', r => { r.createdAt = secret; }],
|
||||
['createdAt', r => { r.createdAt = '2026-02-30'; }, '2026-02-30'],
|
||||
['claim seat', r => { r.claim = { seat: secret, op: 'claimed-op-0001' }; }],
|
||||
]) test(`row ${field} omits semantic value ${value === secret ? 'sentinel' : 'calendar control'}`, () => {
|
||||
const r = doc().rows[0]; edit(r); assert.throws(() => validateRow(r), safe(field, QueueError, { code: 2 }, value));
|
||||
});
|
||||
for (const [field, edit] of [
|
||||
['rev', d => { d.log[0].rev = secret; }], ['verb', d => { d.log[0].verb = secret; }],
|
||||
['semantics', d => { d.log[0].semantics = secret; }], ['revision', d => { d.revision = secret; }],
|
||||
]) test(`queue ${field} omits semantic value from the log/document`, () => {
|
||||
const d = doc(); edit(d); assert.throws(() => loadDoc(Buffer.from(serialize(d))), safe(field, QueueError, { code: 2 }));
|
||||
});
|
||||
test('transport comment omits its value', () => assert.throws(() => checkTransport({ outcome: 'posted', status: 201, comment: secret, detail: 'fixture' }), safe('comment', QueueError, { code: 2 })));
|
||||
test('brief omits its unparseable spec', () => assert.throws(() => parseBriefSpec(secret), safe('brief', QueueError, { code: 2 })));
|
||||
test('manifest duplicate path omits the value', () => assert.throws(() => parseManifest(`${'a'.repeat(64)} ${secret}\n`.repeat(2)), safe('manifest', QueueError, { code: 2 })));
|
||||
|
||||
import { canonArgs, applyEntry, replay } from '../src/queue.mjs';
|
||||
const seatValue = secret.toLowerCase(), digestValue = 'a'.repeat(40);
|
||||
function reviewRow(row, state = 'requesting') {
|
||||
row.state = 'in-review';
|
||||
const posted = state === 'posted';
|
||||
row.review = { rounds: [{ n: 1, op: seatValue, by: 'darkwing', at, issue: 1511,
|
||||
candidate: { kind: 'commit', digest: digestValue, text: null }, request: 'comment',
|
||||
attempts: [{ op: seatValue, by: 'darkwing', at, state, comment: posted ? 1 : null,
|
||||
transport: posted ? { outcome: 'posted', status: 201, comment: 1, detail: 'fixture' } : null, resolutions: [] }],
|
||||
duplicateRisk: false, receipts: [] }] };
|
||||
}
|
||||
function operation(verb, args, edit = () => {}, options = {}) {
|
||||
const state = replay(doc().log), row = state.rows.get(6);
|
||||
edit(row);
|
||||
if (!options.unchecked) validateRow(row);
|
||||
const by = options.by ?? 'sage', op = options.op ?? 'operation-op-0001';
|
||||
const entry = { verb, args: options.raw ? args : canonArgs(verb, { id: 6, ...args }), by, op, at, semantics: 2 };
|
||||
return applyEntry(state, entry, options.resolved ?? {});
|
||||
}
|
||||
const ownerValue = row => { row.owner = seatValue; if (row.claim) row.claim.seat = seatValue; };
|
||||
for (const [name, field, fn, omitted = seatValue] of [
|
||||
['canonical duplicate reviewer', 'repeated value', () => canonArgs('set', { id: 6, field: 'reviewers', value: [seatValue, seatValue] })],
|
||||
['canonical state', 'state', () => canonArgs('move', { id: 6, to: secret }), secret],
|
||||
['canonical verb', 'verb', () => canonArgs(secret, {}), secret],
|
||||
['claimed owner authority', 'owner', () => operation('move', { to: 'blocked', reason: 'pause' }, ownerValue, { by: 'outsider' })],
|
||||
['start owner', 'owner', () => operation('move', { to: 'in-progress' }, r => { ownerValue(r); r.state = 'briefed'; r.claim = null; }, { by: 'outsider' })],
|
||||
['review claimant', 'claimant', () => operation('move', { to: 'in-review', candidate: 'HEAD' }, ownerValue, { by: 'outsider' })],
|
||||
['close gate owner', 'gate owner', () => operation('move', { to: 'done' }, r => { reviewRow(r, 'posted'); r.gateOwner = seatValue; }, { by: 'outsider' })],
|
||||
['set owner reviewer', 'owner', () => operation('set', { field: 'reviewers', value: [seatValue] }, ownerValue)],
|
||||
['set stored piece', 'piece', () => operation('set', { field: 'piece', value: secret }, r => { r.piece = secret; }), secret],
|
||||
['set pinned brief', 'brief', () => { const r = doc().rows.find(r => r.id === 6), brief = { ...r.brief, blob: digestValue };
|
||||
return operation('set', { field: 'brief', value: `${brief.path}#${brief.anchor}` }, r => { r.brief = brief; }, { resolved: { brief } }); }, digestValue],
|
||||
['release claimant', 'claimant', () => operation('release', {}, ownerValue, { by: 'outsider' })],
|
||||
['assign existing owner', 'owned', () => operation('assign', { seat: seatValue }, ownerValue)],
|
||||
['assign reviewer', 'reviewers', () => operation('assign', { seat: seatValue }, r => { r.reviewers = [seatValue]; })],
|
||||
['note owner', 'owner', () => operation('note', { text: 'note' }, ownerValue, { by: 'outsider' })],
|
||||
['add owner reviewer', 'owner', () => operation('add', { piece: 'piece', gate: 'gate', brief: 'docs/brief.md#Brief', owner: seatValue, reviewers: [seatValue] }, () => {},
|
||||
{ resolved: { brief: { path: 'docs/brief.md', anchor: 'Brief', blob: digestValue } } })],
|
||||
['unresolved request', 'unresolved review request', () => operation('review-request', {}, r => reviewRow(r))],
|
||||
['missing approval', 'approval', () => operation('move', { to: 'done' }, r => { reviewRow(r, 'posted'); r.gateOwner = 'sage'; r.reviewers = [seatValue]; })],
|
||||
['missing attempt without round', 'review request', () => operation('review-resolve', { attempt: seatValue, comment: 1 })],
|
||||
['missing attempt in round', 'review request', () => operation('review-resolve', { attempt: seatValue + '-missing', comment: 1 }, r => reviewRow(r))],
|
||||
['posted request', 'posted request', () => operation('review-request', {}, r => reviewRow(r, 'posted'))],
|
||||
['outcome op', 'outcome', () => operation('review-outcome', { attempt: seatValue, outcome: 'posted', status: 201, comment: 1, detail: 'fixture' }, r => reviewRow(r))],
|
||||
['outcome actor', 'outcome', () => operation('review-outcome', { attempt: seatValue, outcome: 'posted', status: 201, comment: 1, detail: 'fixture' }, r => { reviewRow(r); r.review.rounds[0].attempts[0].by = seatValue; },
|
||||
{ by: 'outsider', op: seatValue + '.outcome' })],
|
||||
['duplicate outcome', 'outcome', () => operation('review-outcome', { attempt: seatValue, outcome: 'posted', status: 201, comment: 1, detail: 'fixture' }, r => reviewRow(r, 'posted'),
|
||||
{ by: 'darkwing', op: seatValue + '.outcome' })],
|
||||
['settled resolution', 'request', () => operation('review-resolve', { attempt: seatValue, comment: 1 }, r => reviewRow(r, 'posted'))],
|
||||
['reviewer list', 'reviewer', () => operation('review-record', { verdict: 'approve', comment: 1, candidate: digestValue }, r => { reviewRow(r, 'posted'); r.reviewers = [seatValue]; }, { by: 'outsider' })],
|
||||
['candidate mismatch', 'candidate', () => operation('review-record', { verdict: 'approve', comment: 1, candidate: 'b'.repeat(40) }, r => reviewRow(r, 'posted'), { by: 'filbert' }), digestValue],
|
||||
['duplicate verdict', 'verdict', () => operation('review-record', { verdict: 'approve', comment: 1, candidate: digestValue }, r => { reviewRow(r, 'posted'); r.reviewers = [seatValue];
|
||||
r.review.rounds[0].receipts = [{ reviewer: seatValue, op: 'receipt-op-0001', at, verdict: 'approve', comment: 1, candidate: digestValue }]; }, { by: seatValue })],
|
||||
['legacy candidate evidence', 'candidate', () => operation('move', { to: 'done', evidence: `comment=1,round=1,candidate=${'b'.repeat(40)}` }, r => {
|
||||
reviewRow(r); r.gateOwner = 'sage'; const round = r.review.rounds[0]; round.request = 'none'; delete round.attempts; delete round.duplicateRisk; delete round.receipts;
|
||||
}), digestValue],
|
||||
['direct unknown operation', 'verb', () => operation(secret, {}, () => {}, { raw: true }), secret],
|
||||
// outcomeState also defends direct callers with an inconsistent transport record.
|
||||
['inconsistent outcome record', 'outcome', () => operation('review-outcome', { attempt: seatValue, outcome: 'posted', status: 201, comment: 1, detail: 'fixture' }, r => reviewRow(r, 'failed'),
|
||||
{ by: 'darkwing', op: seatValue + '.outcome', unchecked: true })],
|
||||
]) test(`queue replay refusal ${name} omits stored values`, () => assert.throws(fn, safe(field, QueueError, { code: 2 }, omitted)));
|
||||
|
||||
test('migration map shape omits an unchecked row id', () => assert.throws(() => parseMigrationMap(mapText([{ id: secret }])), safe('migration map row', QueueError, { code: 2 })));
|
||||
test('migration map brief shape omits an unchecked row id', () => {
|
||||
const row = structuredClone(MAP_ROWS[1]); row.id = secret; row.brief = {};
|
||||
assert.throws(() => parseMigrationMap(mapText([row])), safe('brief', QueueError, { code: 2 }));
|
||||
});
|
||||
test('migration map duplicate owner omits unvalidated values', () => {
|
||||
const row = structuredClone(MAP_ROWS[1]); row.owner = secret; row.reviewers = [secret];
|
||||
assert.throws(() => parseMigrationMap(mapText([row])), safe('owner', QueueError, { code: 2 }));
|
||||
});
|
||||
test('BASE control: genesis expected claim already omits the stored op', () => {
|
||||
const d = doc(); d.log[0].op = seatValue; d.log[0].result.receipt = genesisReceipt(seatValue, d.rows.length);
|
||||
assert.throws(() => loadDoc(Buffer.from(serialize(d))), safe('claim', QueueError, { code: 2 }, seatValue));
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
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 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 { loadDoc, parseMigrationMap } from '../src/queue.mjs';
|
||||
import { QueueError } from '../src/errors.mjs';
|
||||
for (const [label, parse] of [
|
||||
['queue.json', () => loadDoc(Buffer.from(malformed))],
|
||||
["migration map's queue-map block", () => parseMigrationMap('```json queue-map\n' + malformed + '\n```')],
|
||||
]) test(`${label} refusal omits parser bytes`, () => {
|
||||
assert.throws(parse, safe(label, QueueError, { code: 2 }));
|
||||
});
|
||||
@@ -115,7 +115,7 @@ test("a request posts once as the requester; a retry sends nothing", async (t) =
|
||||
const again = ok(s.run(move9(), "darkwing"), /\(already recorded at rev 3\)$/m);
|
||||
assert.match(again.err, /request review-9-00001 is posted \(row 9 round 1 on #1508\), comment 1000; nothing was sent again/);
|
||||
assert.equal(s.calls().length, 2);
|
||||
no(s.run(request("request-9-0002"), "darkwing"), 2, /round 1 already has a posted request \(review-9-00001, comment 1000\)/);
|
||||
no(s.run(request("request-9-0002"), "darkwing"), 2, /round 1 already has a posted request/);
|
||||
no(s.run(["review", "outcome", "9"], "darkwing"), 4, /review takes request, resolve, abandon, record or verify-commit/);
|
||||
throwsCode(() => s.mut({ verb: "review-outcome", op: "review-9-00001.outcome", args: { id: 9, attempt: "review-9-00001", outcome: "failed", status: 403, comment: null, detail: "x" }, by: "darkwing" }), 2, /records its own outcome/);
|
||||
});
|
||||
@@ -238,10 +238,10 @@ test("an unresolved request blocks a new request, a new round, waiting-on-jason
|
||||
no(s.run(move9(), "darkwing"), 3, /^Look on #1508 for a comment carrying <!-- mosaic-queue-op: review-9-00001 -->\.$/m);
|
||||
s.rules([]);
|
||||
const head = s.head();
|
||||
no(s.run(request("request-9-0002"), "darkwing"), 2, /unresolved review request: review-9-00001 \(round 1, uncertain\)/);
|
||||
no(s.run(request("request-9-0002"), "darkwing"), 2, /unresolved review request/);
|
||||
ok(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "77", "--candidate", head, "--op", "record-9-filbert1"], "filbert"));
|
||||
no(s.run(["move", "9", "done", "--op", "done-9-000001"], "filbert"), 2, /unresolved review request: review-9-00001 \(round 1, uncertain\); resolve or abandon it before closing it/);
|
||||
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /unresolved review request: review-9-00001 \(round 1, uncertain\); resolve or abandon it before moving it to waiting-on-jason/);
|
||||
no(s.run(["move", "9", "done", "--op", "done-9-000001"], "filbert"), 2, /unresolved review request; resolve or abandon it before closing it/);
|
||||
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /unresolved review request; resolve or abandon it before moving it to waiting-on-jason/);
|
||||
assert.equal(s.posts().length, 1);
|
||||
|
||||
// A POST that lands after the row reached waiting-on-jason: abandoned,
|
||||
@@ -256,7 +256,7 @@ test("an unresolved request blocks a new request, a new round, waiting-on-jason
|
||||
};
|
||||
assert.equal(s3.mut({ verb: "move", op: "review-9-00001", args: { id: 9, to: "in-review", candidate: "HEAD" }, by: "darkwing" }, { hook }).code, 3);
|
||||
assert.deepEqual([s3.row(9).state, s3.round(9, 1).attempts[0].state], ["waiting-on-jason", "conflict"]);
|
||||
no(s3.run(["move", "9", "done", "--op", "done-9-000002"], "jason"), 2, /unresolved review request: review-9-00001 \(round 1, conflict\); resolve or abandon it before closing it/);
|
||||
no(s3.run(["move", "9", "done", "--op", "done-9-000002"], "jason"), 2, /unresolved review request; resolve or abandon it before closing it/);
|
||||
|
||||
// Back to in-progress is allowed; the next round is not.
|
||||
const s2 = await ready(t);
|
||||
@@ -264,7 +264,7 @@ test("an unresolved request blocks a new request, a new round, waiting-on-jason
|
||||
no(s2.run(move9(), "darkwing"), 3);
|
||||
s2.rules([]);
|
||||
ok(s2.run(["move", "9", "in-progress", "--op", "changes-9-0001"], "darkwing"));
|
||||
no(s2.run(["move", "9", "in-review", "--candidate", "HEAD", "--op", "review-9-00002"], "darkwing"), 2, /unresolved review request: review-9-00001 \(round 1, uncertain\); resolve or abandon it before a new round/);
|
||||
no(s2.run(["move", "9", "in-review", "--candidate", "HEAD", "--op", "review-9-00002"], "darkwing"), 2, /unresolved review request; resolve or abandon it before a new round/);
|
||||
assert.equal(s2.posts().length, 1);
|
||||
});
|
||||
|
||||
@@ -326,7 +326,7 @@ test("late outcomes: after an abandon, and after a resolve with the same or anot
|
||||
let a = s.round(9, 1).attempts[0];
|
||||
assert.deepEqual([a.state, a.comment, a.transport.comment, a.resolutions.map((x) => x.verb)], ["conflict", null, 1000, ["abandon"]]);
|
||||
assert.equal(s.round(9, 1).duplicateRisk, true);
|
||||
no(s.run(request("request-9-0002"), "darkwing"), 2, /unresolved review request: review-9-00001 \(round 1, conflict\)/);
|
||||
no(s.run(request("request-9-0002"), "darkwing"), 2, /unresolved review request/);
|
||||
ok(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "1000", "--op", "resolve-9-00001"], "darkwing"), /conflict→posted comment 1000$/m);
|
||||
a = s.round(9, 1).attempts[0];
|
||||
assert.deepEqual([a.state, a.comment, a.resolutions.map((x) => x.verb)], ["posted", 1000, ["abandon", "resolve"]]);
|
||||
@@ -407,7 +407,7 @@ test("resolve checks the comment: issue, markers, round, candidate and author",
|
||||
no(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "7001", "--op", "resolve-9-00001"], "darkwing"), 1, /failed or had no answer/);
|
||||
no(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "7001", "--op", "resolve-9-00001"], "darkwing", { MOSAIC_GITEA_CREDENTIAL_FILE: s.tokens.dewey }), 2, /cannot check comment 7001: .*not darkwing's token file/);
|
||||
const before = s.calls().length;
|
||||
no(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "7001", "--op", "resolve-9-00001"], "rocko"), 2, /^queue: row 9 is claimed by darkwing; only the owner \(darkwing\) or a privileged actor may resolve a request on row 9$/m);
|
||||
no(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "7001", "--op", "resolve-9-00001"], "rocko"), 2, /^queue: row 9 is claimed; only the owner or a privileged actor may resolve a request on row 9$/m);
|
||||
assert.equal(s.calls().length, before, "a refused actor makes no request");
|
||||
assert.equal(s.doc().revision, 4);
|
||||
s.rules([{ method: "GET", path: "comments/7001$", http: 200, body: good }]);
|
||||
@@ -463,9 +463,9 @@ test("request, changes, a new candidate, approval: every round pinned; no review
|
||||
no(s.run(["review", "record", "9", "--verdict", "maybe", "--comment", "88", "--candidate", c1, "--op", "record-9-filb01"], "filbert"), 2, /approve or changes/);
|
||||
no(s.run(["review", "record", "9", "--verdict", "changes", "--candidate", c1, "--op", "record-9-filb01"], "filbert"), 4, /--comment N is required/);
|
||||
ok(s.run(["review", "record", "9", "--verdict", "changes", "--comment", "88", "--candidate", c1, "--op", "record-9-filb01"], "filbert"), /row 9 round 1 changes by filbert \(comment 88\)$/m);
|
||||
no(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "89", "--candidate", c1, "--op", "record-9-filb02"], "filbert"), 2, /filbert already recorded a verdict for row 9 round 1/);
|
||||
no(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "89", "--candidate", c1, "--op", "record-9-filb02"], "filbert"), 2, /reviewer already recorded a verdict for row 9 round 1/);
|
||||
ok(cli(s.repo, ["next", "filbert"]), /^nothing$/m);
|
||||
no(s.run(["move", "9", "done", "--op", "done-9-000001"], "filbert"), 2, /round 1 has no approval recorded by filbert/);
|
||||
no(s.run(["move", "9", "done", "--op", "done-9-000001"], "filbert"), 2, /round 1 has no approval recorded by every listed reviewer/);
|
||||
ok(s.run(["move", "9", "in-progress", "--op", "changes-9-0001"], "darkwing"));
|
||||
no(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "89", "--candidate", c1, "--op", "record-9-filb02"], "filbert"), 2, /is in-progress; review record applies to in-review rows/);
|
||||
writeFileSync(join(s.repo.root, "packages/fix.mjs"), "export {};\n");
|
||||
@@ -474,10 +474,10 @@ test("request, changes, a new candidate, approval: every round pinned; no review
|
||||
const c2 = s.head();
|
||||
ok(s.run(move9("review-9-00002"), "darkwing"), /round 2 on #1508; request review-9-00002 requesting$/m);
|
||||
ok(cli(s.repo, ["next", "filbert"]), /^review row 9/m);
|
||||
no(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "89", "--candidate", c1, "--op", "record-9-filb02"], "filbert"), 2, new RegExp(`candidate ${c1} is not round 2's candidate ${c2}`));
|
||||
no(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "89", "--candidate", c1, "--op", "record-9-filb02"], "filbert"), 2, /candidate is not round 2's candidate/);
|
||||
ok(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "89", "--candidate", c2, "--op", "record-9-filb02"], "filbert"));
|
||||
no(s.run(["move", "9", "done", "--evidence", `comment=89,round=2,candidate=${c2}`, "--op", "done-9-000001"], "filbert"), 2, /closes on its recorded verdicts; drop --evidence/);
|
||||
no(s.run(["move", "9", "done", "--op", "done-9-000001"], "rocko"), 2, /only the gate owner \(filbert\) or a privileged actor/);
|
||||
no(s.run(["move", "9", "done", "--op", "done-9-000001"], "rocko"), 2, /only the gate owner or a privileged actor/);
|
||||
ok(s.run(["move", "9", "done", "--op", "done-9-000001"], "filbert"), /in-review→done round 2$/m);
|
||||
const rounds = s.row(9).review.rounds;
|
||||
assert.deepEqual(rounds.map((r) => [r.n, r.candidate.digest, r.attempts.map((a) => [a.op, a.state, a.comment]), r.receipts.map((x) => [x.reviewer, x.verdict, x.comment, x.candidate])]), [
|
||||
@@ -565,11 +565,11 @@ test("semantics: v1 entries replay as before; review entries need v2", async (t)
|
||||
const outcome = d.log.at(-1);
|
||||
assert.throws(() => queue.loadDoc(Buffer.from(queue.serialize({ ...d, log: [...d.log.slice(0, -1), { ...outcome, semantics: 1 }] }))), /review-outcome needs semantics 2/);
|
||||
assert.throws(() => queue.loadDoc(Buffer.from(queue.serialize({ ...d, log: [...d.log.slice(0, -1), { ...outcome, op: "review-9-00002.outcome" }] }))), /op must be its attempt's op plus \.outcome/);
|
||||
assert.throws(() => queue.loadDoc(Buffer.from(queue.serialize({ ...d, log: d.log.map((e, i) => (i === 1 ? { ...e, semantics: 3 } : e)) }))), /semantics 3 is not 1 to 2/);
|
||||
assert.throws(() => queue.loadDoc(Buffer.from(queue.serialize({ ...d, log: d.log.map((e, i) => (i === 1 ? { ...e, semantics: 3 } : e)) }))), /semantics is not 1 to 2/);
|
||||
// An attempt takes one outcome, even one that agrees with it.
|
||||
const full = queue.replay(d.log);
|
||||
const again = { ...outcome, rev: full.revision + 1 };
|
||||
assert.throws(() => queue.applyEntry(full, again, {}), /request review-9-00001 already has its outcome/);
|
||||
assert.throws(() => queue.applyEntry(full, again, {}), /request already has its outcome/);
|
||||
// review-outcome is the one entry a done row takes.
|
||||
const at = "2026-09-27T00:00:00.000Z";
|
||||
// The request was abandoned and the row closed before the POST's answer came.
|
||||
@@ -585,7 +585,7 @@ test("semantics: v1 entries replay as before; review entries need v2", async (t)
|
||||
assert.deepEqual([late.state.rows.get(9).state, a.state, a.transport.comment], ["done", "conflict", 1000]);
|
||||
const lateOk = (verb, op, args, by) => queue.applyEntry(st, entry(st, verb, op, args, by), {});
|
||||
assert.throws(() => lateOk("review-resolve", "resolve-9-00001", { id: 9, attempt: "review-9-00001", comment: 5 }), /done rows never change/);
|
||||
assert.throws(() => queue.applyEntry(mid, entry(mid, "review-outcome", "review-9-00001.outcome", { id: 9, attempt: "review-9-00001", outcome: "failed", status: 403, comment: null, detail: "x" }, "sage"), {}), /only darkwing, who made request review-9-00001, records its outcome/);
|
||||
assert.throws(() => queue.applyEntry(mid, entry(mid, "review-outcome", "review-9-00001.outcome", { id: 9, attempt: "review-9-00001", outcome: "failed", status: 403, comment: null, detail: "x" }, "sage"), {}), /only the actor who made the request records its outcome/);
|
||||
assert.throws(() => queue.canonArgs("review-outcome", { id: 9, attempt: "review-9-00001", outcome: "failed", status: 500, comment: null, detail: "x" }), /a failed outcome's status is one of 400, 401, 403, 404, 422/);
|
||||
assert.throws(() => queue.canonArgs("review-outcome", { id: 9, attempt: "review-9-00001", outcome: "posted", status: 201, comment: null, detail: "x" }), /posted exactly when HTTP 201 returned a comment id/);
|
||||
assert.throws(() => queue.canonArgs("review-outcome", { id: 9, attempt: "review-9-00001", outcome: "posted", status: 200, comment: 4, detail: "x" }), /posted exactly when HTTP 201/);
|
||||
@@ -593,7 +593,7 @@ test("semantics: v1 entries replay as before; review entries need v2", async (t)
|
||||
|
||||
test("set reviewers refuses the row's owner (2026-09-28)", async (t) => {
|
||||
const s = await ready(t);
|
||||
no(s.run(["set", "9", "reviewers", "filbert,darkwing", "--op", "reviewers-9-self"], "sage"), 2, /row 9 owner darkwing can't be a listed reviewer/);
|
||||
no(s.run(["set", "9", "reviewers", "filbert,darkwing", "--op", "reviewers-9-self"], "sage"), 2, /row 9 owner can't be a listed reviewer/);
|
||||
assert.deepEqual(s.row(9).reviewers, ["filbert"]);
|
||||
});
|
||||
|
||||
@@ -665,17 +665,17 @@ test("a Jason-gated row reaches waiting-on-jason only on every reviewer's approv
|
||||
ok(s.run(move9(), "darkwing"), /posted comment 1000$/m);
|
||||
assert.deepEqual(s.round(9, 1).receipts, []);
|
||||
no(s.run(["move", "9", "done", "--op", "done-9-000001"], "darkwing"), 2, /gate is Jason's; it goes through waiting-on-jason/);
|
||||
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /row 9 round 1 has no approval recorded by filbert, rocko$/m);
|
||||
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "sage"), 2, /no approval recorded by filbert, rocko$/m);
|
||||
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /row 9 round 1 has no approval recorded by every listed reviewer$/m);
|
||||
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "sage"), 2, /no approval recorded by every listed reviewer$/m);
|
||||
ok(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "88", "--candidate", head, "--op", "record-9-filb01"], "filbert"));
|
||||
ok(s.run(["review", "record", "9", "--verdict", "changes", "--comment", "89", "--candidate", head, "--op", "record-9-rock01"], "rocko"));
|
||||
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /no approval recorded by rocko$/m);
|
||||
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /no approval recorded by every listed reviewer$/m);
|
||||
// Changes go back to in-progress, as before.
|
||||
ok(s.run(["move", "9", "in-progress", "--op", "changes-9-0001"], "darkwing"));
|
||||
ok(s.run(move9("review-9-00002"), "darkwing"), /posted comment 1001$/m);
|
||||
ok(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "91", "--candidate", head, "--op", "record-9-rock02"], "rocko"));
|
||||
// Filbert's round 1 approval doesn't carry into round 2.
|
||||
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /row 9 round 2 has no approval recorded by filbert$/m);
|
||||
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /row 9 round 2 has no approval recorded by every listed reviewer$/m);
|
||||
ok(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "90", "--candidate", head, "--op", "record-9-filb02"], "filbert"));
|
||||
ok(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), /in-review→waiting-on-jason$/m);
|
||||
ok(s.run(["move", "9", "done", "--op", "done-9-000001"], "jason"), /waiting-on-jason→done$/m);
|
||||
|
||||
@@ -65,7 +65,7 @@ test("genesis: the map must be committed, well formed, with committed briefs and
|
||||
no(cli(noBrief, genesisArgs(noBrief), { by: "sage" }), 2, /only a done row may lack one/);
|
||||
// The owner-not-reviewer rule of add, set and assign (2026-10-04).
|
||||
const selfReview = scratchRepo(t, { map: mapText(MAP_ROWS.map((r) => (r.id === 9 ? { ...r, reviewers: ["darkwing", "filbert"] } : r))) });
|
||||
no(cli(selfReview, genesisArgs(selfReview), { by: "sage" }), 2, /migration map row 9 owner darkwing can't be a listed reviewer/);
|
||||
no(cli(selfReview, genesisArgs(selfReview), { by: "sage" }), 2, /migration map row owner can't be a listed reviewer/);
|
||||
for (const repo of [ghost, noBrief, selfReview]) {
|
||||
assert.throws(() => readFileSync(repo.queuePath));
|
||||
assert.throws(() => readFileSync(join(repo.gitDir, "mosaic-queue.head")));
|
||||
@@ -245,11 +245,11 @@ test("claims and add defaults through the CLI; candidates are manifests or reach
|
||||
test("add, set reviewers and assign refuse the row's owner as a reviewer", (t) => {
|
||||
const repo = ready(t);
|
||||
const before = doc(repo);
|
||||
no(cli(repo, ["add", "--op", "add-owner-reviewer", "--piece", "x", "--gate", "g", "--brief", "docs/plans/brief-b.md#Template", "--owner", "rocko", "--gate-owner", "filbert", "--reviewer", "rocko"], { by: "sage" }), 2, /row 12 owner rocko can't be a listed reviewer/);
|
||||
no(cli(repo, ["add", "--op", "add-owner-reviewer", "--piece", "x", "--gate", "g", "--brief", "docs/plans/brief-b.md#Template", "--owner", "rocko", "--gate-owner", "filbert", "--reviewer", "rocko"], { by: "sage" }), 2, /row 12 owner can't be a listed reviewer/);
|
||||
assert.deepEqual(doc(repo), before);
|
||||
no(cli(repo, ["set", "9", "reviewers", "filbert,darkwing", "--op", "set-9-owner-reviewer"], { by: "sage" }), 2, /row 9 owner darkwing can't be a listed reviewer/);
|
||||
no(cli(repo, ["set", "9", "reviewers", "filbert,darkwing", "--op", "set-9-owner-reviewer"], { by: "sage" }), 2, /row 9 owner can't be a listed reviewer/);
|
||||
assert.deepEqual(doc(repo), before);
|
||||
no(cli(repo, ["assign", "9", "filbert", "--op", "assign-9-filbert"], { by: "sage" }), 2, /can't assign row 9 to filbert: filbert is one of its reviewers/);
|
||||
no(cli(repo, ["assign", "9", "filbert", "--op", "assign-9-filbert"], { by: "sage" }), 2, /can't assign row 9: seat is one of its reviewers/);
|
||||
assert.deepEqual(doc(repo), before);
|
||||
// The unchanged accept paths: a reviewer list or a new owner that does not collide with the other.
|
||||
ok(cli(repo, ["set", "9", "reviewers", "filbert,dewey", "--op", "set-9-ok"], { by: "sage" }));
|
||||
|
||||
@@ -31,8 +31,8 @@ export function readActivePointer(dataRoot) {
|
||||
let pointer;
|
||||
try {
|
||||
pointer = JSON.parse(state.text);
|
||||
} catch (error) {
|
||||
throw new RunsError(`release pointer is not valid JSON (${state.file}): ${error.message}`, 2);
|
||||
} catch {
|
||||
throw new RunsError(`release pointer is not valid JSON (${state.file})`, 2);
|
||||
}
|
||||
const invalid = (why) => new RunsError(`release pointer ${why} (${state.file})`, 2);
|
||||
if (typeof pointer !== "object" || pointer === null || Array.isArray(pointer)) throw invalid("must be a JSON object");
|
||||
@@ -67,7 +67,7 @@ function logEntry(line) {
|
||||
// With last, only the newest last well-formed entries are returned.
|
||||
export function readActivationLog(dataRoot, { last } = {}) {
|
||||
if (last !== undefined && (!Number.isInteger(last) || last < 1)) {
|
||||
throw new RunsError(`last must be a positive integer (got ${JSON.stringify(last)})`);
|
||||
throw new RunsError(`last must be a positive integer`);
|
||||
}
|
||||
const state = readStateFile(dataRoot, ACTIVATION_LOG_FILE);
|
||||
if (state === null) return { entries: [], malformed: 0 };
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
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 { readActivationLog } from '../src/state.mjs';
|
||||
import { RunsError } from '../src/errors.mjs';
|
||||
test('activation last refusal names the field without its value', () => assert.throws(() => readActivationLog('/unused', { last: secret }), safe('last', RunsError, { exitCode: 4 })));
|
||||
@@ -0,0 +1,34 @@
|
||||
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 { RunsError, readActivePointer } from '../src/index.mjs';
|
||||
test('release pointer JSON refusal names only the file', t => {
|
||||
const root = scratch(t), file = write(join(root, 'state/active.json'));
|
||||
assert.throws(() => readActivePointer(root), safe(file, RunsError, { exitCode: 2 }));
|
||||
});
|
||||
@@ -60,6 +60,8 @@ export const RUNNER_FILES = Object.freeze([
|
||||
"packages/harness/src/pi-extension.mjs",
|
||||
"packages/bus/src/client.mjs",
|
||||
"packages/bus/src/broker.mjs",
|
||||
"packages/bus/src/refusals.mjs",
|
||||
"packages/organization/src/rails.mjs",
|
||||
"packages/bus/src/proxy-page-vocabulary.mjs",
|
||||
// Row 331 r5: parsing only; common.mjs and PIN remain outside the sandbox.
|
||||
"packages/model-proxy/src/validation.mjs",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
// Sessions live in <seatDir>/.pi/agent/sessions.
|
||||
// Anything else is layout "unknown" and records nulls; nothing is guessed.
|
||||
|
||||
import { parseJsonFileText } from "../../organization/src/util.mjs";
|
||||
import { existsSync, readFileSync, mkdirSync, writeFileSync, renameSync, statSync, realpathSync } from "node:fs";
|
||||
import { join, basename, dirname, isAbsolute, resolve } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
@@ -53,9 +54,9 @@ export function loadDataRoot(path = defaultConfigPath()) {
|
||||
if (!existsSync(path)) throw new SeatError(`config not found: ${path}`);
|
||||
let raw;
|
||||
try {
|
||||
raw = JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch (err) {
|
||||
throw new SeatError(`config is not valid JSON: ${path} (${err.message})`);
|
||||
raw = parseJsonFileText(readFileSync(path, "utf8"), path);
|
||||
} catch (error) {
|
||||
throw new SeatError(`config is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`);
|
||||
}
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new SeatError(`config is not an object: ${path}`);
|
||||
if (typeof raw.dataRoot !== "string" || !isAbsolute(raw.dataRoot)) throw new SeatError(`config.dataRoot must be an absolute path: ${path}`);
|
||||
@@ -202,9 +203,9 @@ export function readRegistration(seats, seat, layout) {
|
||||
if (!existsSync(path)) return null;
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch (err) {
|
||||
throw new SeatError(`registration is not valid JSON: ${path} (${err.message})`);
|
||||
parsed = parseJsonFileText(readFileSync(path, "utf8"), path);
|
||||
} catch (error) {
|
||||
throw new SeatError(`registration is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`);
|
||||
}
|
||||
try {
|
||||
const record = validateRegistration(parsed);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
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 { SeatError, loadDataRoot, readRegistration, registrationPath } from '../src/seat.mjs';
|
||||
for (const kind of ['config', 'registration']) test(`${kind} JSON refusal names only the file`, t => {
|
||||
const root = scratch(t), file = write(kind === 'config' ? join(root, 'config.json') : registrationPath(root, 'builder', 'repo'));
|
||||
assert.throws(() => kind === 'config' ? loadDataRoot(file) : readRegistration(root, 'builder', 'repo'), safe(file, SeatError, { exitCode: 2 }));
|
||||
});
|
||||
for (const kind of ['config', 'registration']) test(`${kind} read refusal retains the filesystem code`, t => {
|
||||
const root = scratch(t), file = kind === 'config' ? join(root, 'config.json') : registrationPath(root, 'builder', 'repo');
|
||||
mkdirSync(file, { recursive: true });
|
||||
assert.throws(() => kind === 'config' ? loadDataRoot(file) : readRegistration(root, 'builder', 'repo'), error => {
|
||||
safe(file, SeatError, { exitCode: 2 })(error);
|
||||
assert.match(error.message, /\(EISDIR\)$/);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
@@ -351,7 +351,7 @@ test("a confined session's shell reaches its own workspace and run directory and
|
||||
assert.equal(readFileSync(join(x.ws, "probe"), "utf8"), "ws\n");
|
||||
// The harness reaches its tool socket: the refusal is the tool server's,
|
||||
// not a failed connect.
|
||||
assert.equal(await x.ask("TOOL no_such_tool {}"), "refused: unknown-tool");
|
||||
assert.equal((await x.ask("TOOL no_such_tool {}")).split("\n")[0], "refused: unknown-tool");
|
||||
});
|
||||
|
||||
test("a bind that exposes a stand-in, or a missing workspace bind, fails the matching check", { skip, timeout: 60000 }, async (t) => {
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
// #1873 Phase A slice A2: the owner's Settings > Agents writes for the root and project agent levels
|
||||
// (design docs/plans/2026-11-10_agent-levels-design.md sections 6, 7 and 9 item 1).
|
||||
//
|
||||
// GET /api/agents/root root entries, the file's revision, writable
|
||||
// POST /api/agents/root {revision, name, entry}: entry is a patch
|
||||
// {definition?, vars?, excludeOrgs?} (a var
|
||||
// set to null is removed) or null to remove
|
||||
// GET /api/orgs/<org>/agent-levels/projects/<project> the project's entries and the org revision
|
||||
// POST /api/orgs/<org>/agent-levels/projects/<project> {revision, name, entry}: entry is a define
|
||||
// {definition, vars?}, a narrow {vars}, an
|
||||
// exclude {exclude: true} or null to remove
|
||||
//
|
||||
// These are registered hooks, so serve.mjs's hook fence runs first, as it does for
|
||||
// /api/agents/levels: a signed-in session on the page's name, no granted user on a write, and on a
|
||||
// POST the exact Origin and Sec-Fetch-Site: same-origin. An org path is resolved for the session
|
||||
// before the hook runs. Here: the owner's own session only (owner-only otherwise), and a POST only
|
||||
// when the Console was started with writes on (read-only otherwise). The writers then check again:
|
||||
// the root writer takes the session's principal as the owner, and the project writer acts as the
|
||||
// organization's human, as the bus's human transport does.
|
||||
//
|
||||
// A response or trail entry names agents, keys and revisions, never a var value or a host path.
|
||||
import { organizationWriter } from '../../organization/src/organization.mjs';
|
||||
import { changeRootAgent, patchRootEntry, projectAgentSummary, readRootAgentsFile, rootAgentSummary } from '../../organization/src/agent-levels-write.mjs';
|
||||
import { ID_PATTERN } from '../../organization/src/vocabulary.mjs';
|
||||
import { actor } from './action-trail.mjs';
|
||||
|
||||
export const ROOT_ROUTE = '/api/agents/root';
|
||||
export const PROJECT_ROUTE = /^\/api\/orgs\/([^/]+)\/agent-levels\/projects\/([^/]+)$/;
|
||||
// routeTable takes GET and POST only; any other method never reaches these hooks (serve.mjs answers it).
|
||||
const METHODS = ['GET', 'POST'];
|
||||
const REVISION = /^[a-f0-9]{64}$/;
|
||||
// Agent and project names are the writers' ids (requireId): checked here so a bad one is a 400
|
||||
// naming the field, not a writer error the Console can't classify.
|
||||
const NAME = ID_PATTERN;
|
||||
const NAME_RULE = 'lowercase letters, digits, dot, dash or underscore, at most 64';
|
||||
const LIMIT = 4096;
|
||||
const ROOT_PATCH = ['definition', 'vars', 'excludeOrgs'];
|
||||
const PROJECT_ENTRY = ['definition', 'vars', 'exclude'];
|
||||
const STATUS = {
|
||||
'invalid-request': 400, 'agents-invalid': 400, 'project-agents-invalid': 400, 'project-agents-library': 400, 'no-change': 400,
|
||||
'human-required': 403, 'owner-only': 403, 'read-only': 403,
|
||||
'project-not-found': 404, 'agent-not-found': 404,
|
||||
'revision-conflict': 409, 'agents-locked': 409, 'organization-busy': 409,
|
||||
'agents-unsafe': 503, 'organization-uncertain': 503,
|
||||
};
|
||||
// The writers' own text for these codes names keys and ids only (A1's checks and the A2 scrubber).
|
||||
const SAYS = new Set(['agents-invalid', 'project-agents-invalid', 'project-agents-library', 'no-change', 'human-required',
|
||||
'project-not-found', 'agent-not-found', 'revision-conflict', 'agents-locked', 'organization-busy', 'agents-unsafe']);
|
||||
const FIXED = {
|
||||
'outcome-unknown': 'the change may have been made; nothing was retried. Read the agents again before trying',
|
||||
committed: 'the change was written; its receipt may be missing. Read the agents again before trying',
|
||||
};
|
||||
|
||||
const one = (req, key) => req.headersDistinct[key]?.length === 1 ? req.headersDistinct[key][0] : null;
|
||||
function json(res, status, value) {
|
||||
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
||||
res.end(JSON.stringify(value));
|
||||
}
|
||||
const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
// Every string a var value carries, and each non-string value as JSON: a refusal that quotes any of
|
||||
// them gets a fixed line instead.
|
||||
function values(entry) {
|
||||
const out = [];
|
||||
const walk = v => {
|
||||
if (typeof v === 'string') { if (v.length >= 3) out.push(v); }
|
||||
else if (Array.isArray(v)) v.forEach(walk);
|
||||
else if (plain(v)) Object.entries(v).forEach(([k, x]) => { if (k.length >= 3) out.push(k); walk(x); });
|
||||
else if (v !== null && v !== undefined) out.push(JSON.stringify(v));
|
||||
};
|
||||
if (plain(entry?.vars)) Object.values(entry.vars).forEach(walk);
|
||||
return out.filter(s => s.length >= 3);
|
||||
}
|
||||
|
||||
// A coded refusal's own text when it names no value and no path; a fixed line otherwise.
|
||||
function refusal(error, entry) {
|
||||
const code = typeof error?.code === 'string' && Object.hasOwn(STATUS, error.code) ? error.code : 'outcome-unknown';
|
||||
if (error?.committed) return { status: 503, body: { error: 'receipt-missing', committed: true, message: FIXED.committed } };
|
||||
const text = typeof error?.message === 'string' ? error.message : '';
|
||||
const quoted = values(entry).some(v => text.includes(v));
|
||||
const message = SAYS.has(code) && text && text.length <= 400 && !text.includes('/') && !quoted ? text : (FIXED[code] ?? 'refused');
|
||||
return { status: STATUS[code] ?? 503, body: { error: code, message } };
|
||||
}
|
||||
|
||||
async function body(req) {
|
||||
if (one(req, 'content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') return null;
|
||||
const chunks = []; let size = 0;
|
||||
for await (const chunk of req) { size += chunk.length; if (size > LIMIT) return 'too-large'; chunks.push(chunk); }
|
||||
let value;
|
||||
try { value = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { return null; }
|
||||
const keys = ['revision', 'name', 'entry'];
|
||||
if (!plain(value) || Object.keys(value).length !== keys.length || !keys.every(k => Object.hasOwn(value, k))) return null;
|
||||
if (typeof value.revision !== 'string' || !REVISION.test(value.revision) || typeof value.name !== 'string') return null;
|
||||
if (!NAME.test(value.name)) return 'bad-name';
|
||||
return value;
|
||||
}
|
||||
const only = (entry, keys) => plain(entry) && Object.keys(entry).length > 0 && Object.keys(entry).every(k => keys.includes(k));
|
||||
|
||||
// `configDir` and `rolesDir` are the instance's; `trail` is the Console action trail.
|
||||
export function agentLevelsWriteRoutes({ configDir, rolesDir, trail = null, log = () => {} }) {
|
||||
const writer = organizationWriter({ dir: configDir, rolesDir });
|
||||
const note = (session, entry) => {
|
||||
if (!trail) return;
|
||||
try { trail({ by: actor(session), ...entry }); }
|
||||
catch { log(`${new Date().toISOString()} trail write failed for ${entry.action}`); }
|
||||
};
|
||||
// The fence every request takes after serve.mjs's: method, owner, writes on, then the body.
|
||||
async function gate(req, res, { session, owner, send }) {
|
||||
if (!['GET', 'POST'].includes(req.method)) { json(res, 405, { error: 'method-not-allowed' }); return null; }
|
||||
if (!owner || !session) { json(res, 403, { error: 'owner-only', message: 'agent levels are the owner\'s' }); return null; }
|
||||
if (req.method === 'GET') return {};
|
||||
if (!send) { json(res, 403, { error: 'read-only', message: 'this Console was started without writes' }); return null; }
|
||||
const value = await body(req);
|
||||
if (value === 'too-large') { json(res, 413, { error: 'invalid-request', message: `a change is at most ${LIMIT} bytes` }); return null; }
|
||||
if (value === 'bad-name') { json(res, 400, { error: 'invalid-request', message: `name must be an id: ${NAME_RULE}` }); return null; }
|
||||
if (!value) { json(res, 400, { error: 'invalid-request', message: 'this takes revision, name and entry, nothing else' }); return null; }
|
||||
return value;
|
||||
}
|
||||
|
||||
async function root(req, res, context) {
|
||||
const value = await gate(req, res, context);
|
||||
if (!value) return;
|
||||
const { session } = context;
|
||||
if (req.method === 'GET') {
|
||||
try {
|
||||
const read = await readRootAgentsFile(configDir);
|
||||
return json(res, 200, { revision: read.revision, state: read.exists ? 'present' : 'absent', agents: rootAgentSummary(read.document), writable: Boolean(context.send) });
|
||||
} catch (error) { const r = refusal(error, null); return json(res, r.status, r.body); }
|
||||
}
|
||||
const { revision, name, entry } = value;
|
||||
if (entry !== null && !only(entry, ROOT_PATCH)) return json(res, 400, { error: 'invalid-request', message: 'entry is null or takes definition, vars and excludeOrgs' });
|
||||
if (entry?.vars !== undefined && !plain(entry.vars)) return json(res, 400, { error: 'invalid-request', message: 'vars is an object of keys' });
|
||||
const action = entry === null ? 'agents.root.remove' : 'agents.root.set';
|
||||
const principal = { provider: session.provider, iss: session.iss, sub: session.sub };
|
||||
let receipt;
|
||||
try {
|
||||
receipt = await changeRootAgent(configDir, { revision, name, entry: entry === null ? null : old => patchRootEntry(old, entry) }, { actor: principal, owner: principal, rolesDir });
|
||||
} catch (error) {
|
||||
const r = refusal(error, entry);
|
||||
note(session, { action, agent: name, outcome: r.body.error });
|
||||
return json(res, r.status, r.body);
|
||||
}
|
||||
note(session, { action, agent: name, changed: receipt.changed, outcome: 'done' });
|
||||
return json(res, 200, { outcome: 'done', receipt });
|
||||
}
|
||||
|
||||
async function project(req, res, context) {
|
||||
const value = await gate(req, res, context);
|
||||
if (!value) return;
|
||||
const { session, orgId } = context;
|
||||
let projectId;
|
||||
try { projectId = decodeURIComponent(PROJECT_ROUTE.exec(context.path)?.[2] ?? ''); } catch { projectId = ''; }
|
||||
if (typeof orgId !== 'string' || !NAME.test(projectId)) return json(res, 404, { error: 'project-not-found', message: 'no such project' });
|
||||
if (req.method === 'GET') {
|
||||
try {
|
||||
const { document, revision } = writer.read(orgId);
|
||||
const target = document.projects?.[projectId];
|
||||
if (!plain(target)) return json(res, 404, { error: 'project-not-found', message: `projects.${projectId} does not exist` });
|
||||
return json(res, 200, { organization: orgId, project: projectId, revision, library: target.kind === 'library', agents: projectAgentSummary(target.agents ?? {}), writable: Boolean(context.send) });
|
||||
} catch (error) { const r = refusal(error, null); return json(res, r.status, r.body); }
|
||||
}
|
||||
const { revision, name, entry } = value;
|
||||
if (entry !== null && !only(entry, PROJECT_ENTRY)) return json(res, 400, { error: 'invalid-request', message: 'entry is null or takes definition, vars or exclude' });
|
||||
const form = entry === null ? 'remove' : Object.hasOwn(entry, 'exclude') ? 'exclude' : Object.hasOwn(entry, 'definition') ? 'define' : 'narrow';
|
||||
const base = { org: orgId, action: 'project.agents.set', project: projectId, agent: name, form };
|
||||
let result;
|
||||
// The Console's owner is each organization's human, as the bus binds it (bindConsole with the
|
||||
// document's human; request-bootstrap's addProject passes document.human the same way). The
|
||||
// owner gate above is the identity check; the writer re-reads under its lock and refuses if the
|
||||
// human changed since this read.
|
||||
try { result = writer.setProjectAgent(orgId, { revision, project: projectId, name, entry }, writer.read(orgId).document.human); }
|
||||
catch (error) {
|
||||
const r = refusal(error, entry);
|
||||
note(session, { ...base, outcome: r.body.error });
|
||||
return json(res, r.status, r.body);
|
||||
}
|
||||
note(session, { ...base, outcome: 'done' });
|
||||
return json(res, 200, { outcome: 'done', receipt: { verb: 'project.agents.set', organization: orgId, project: projectId, agent: name, form, revisionBefore: revision, revisionAfter: result.revision, transaction: result.transaction } });
|
||||
}
|
||||
|
||||
return table => {
|
||||
for (const method of METHODS) {
|
||||
table.add(method, ROOT_ROUTE, root);
|
||||
table.add(method, PROJECT_ROUTE, project);
|
||||
}
|
||||
};
|
||||
}
|
||||
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user