Compare commits

..
11 Commits
Author SHA1 Message Date
jason.woltje a3e82efe23 fix(redaction): close independent review findings (#1873) 2026-11-10 19:56:05 -06:00
jason.woltje c8ff8ea128 fix(redaction): omit remaining configuration values from refusals (#1873) 2026-11-10 19:56:05 -06:00
jason.woltje ddf4f895cd fix(rails): close review guidance and coverage gaps (#1873) 2026-11-10 19:42:36 -06:00
jason.woltje 7035ce61b3 feat(harness): expose caller rails and refusal guidance (#1873) 2026-11-10 18:51:15 -06:00
jason.woltje 54bf239e32 test(webui): v1 business golden carries RR's resolve rights (#1873) 2026-11-10 18:31:23 -06:00
jason.woltje a4eed1e219 fix(agents): A2 review round: config check, name rule, drafts and test gaps (#1873)
The CLI agent verbs validate the instance config through loadSystem before
any read or write, and print a fixed line for errors they do not own. The
root writer refuses a config directory with an ancestor another user can
write without the sticky bit. The project HTTP route checks agent names
with the organization ID_PATTERN and answers 400 naming the key.

The Settings level forms take their own class, so the role-instance
handlers no longer catch them, and keep both drafts in module state with
the revision each began at. The organization writer tests now reach the
file's own uid check and test the assistant protection with an assistant
in the fixture.
2026-11-10 18:25:54 -06:00
jason.woltje 1e1bbaa6dd fix(webui): A2 agent level grids scroll inside their card on phones (#1873) 2026-11-10 18:25:54 -06:00
jason.woltje 78bda9d6a4 test(organization): A2 refusal patterns follow var-redact's key-only text (#1873) 2026-11-10 18:25:54 -06:00
jason.woltje 6b39af7888 feat(agents): A2 root and project agent level writers, CLI and Settings forms (#1873)
Root writer for <configDir>/agents.json (owner only, revision-checked, atomic, A1-validated,
receipts name agents and keys only), the project.agents writer verb on the organization
writer, `mosaic agents root` and `mosaic project agents` verbs, and owner-only Settings >
Agents edit forms on GET/POST hooks gated like /api/agents/levels.
2026-11-10 18:25:54 -06:00
jason.woltje 96c900d210 fix: preserve read error codes and launcher fixtures (#1873) 2026-11-10 17:54:33 -06:00
jason.woltje 307fe928ac fix: redact remaining file and config refusal echoes (#1873) 2026-11-10 17:06:54 -06:00
121 changed files with 3780 additions and 366 deletions

No files matched your search

+7
View File
@@ -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). |
+11 -1
View File
@@ -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');
+240
View File
@@ -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;
+10 -9
View File
@@ -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) => {
+3 -2
View File
@@ -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 => {
+2 -1
View File
@@ -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);
});
+4 -3
View File
@@ -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';",
+147
View File
@@ -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/);
});
+3 -2
View File
@@ -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.
+5 -1
View File
@@ -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";
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
+195
View File
@@ -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('');
}
+6
View File
@@ -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);
+1 -1
View File
@@ -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 });
}
+1 -1
View File
@@ -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/);
});
+9 -3
View File
@@ -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();
});
+2 -1
View File
@@ -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';
+1 -1
View File
@@ -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"`);
+91
View File
@@ -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()));
});
+2 -2
View File
@@ -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"}');
+4 -4
View File
@@ -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"));
+7 -6
View File
@@ -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;
});
});
+4 -3
View File
@@ -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);
+4 -3
View File
@@ -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;
+11 -10
View File
@@ -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 {
+1 -1
View File
@@ -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);
+7 -5
View File
@@ -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
+1 -1
View File
@@ -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;
});
});
+1 -1
View File
@@ -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" } } }));
+4 -2
View File
@@ -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";
}
+16 -2
View File
@@ -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 -2
View File
@@ -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"));
}
+2 -2
View File
@@ -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']);
});
+1 -1
View File
@@ -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"]);
});
+62
View File
@@ -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'));
});
+16 -3
View File
@@ -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",
]);
+8 -8
View File
@@ -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'));
});
+3
View File
@@ -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 }));
+15 -15
View File
@@ -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';
+2 -2
View File
@@ -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 } : {}),
+15 -2
View File
@@ -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";
}
+37 -3
View File
@@ -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)) };
}
+1 -1
View File
@@ -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);
}
+54
View 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');
}
+2
View File
@@ -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,
+18 -18
View File
@@ -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`);
+5 -5
View File
@@ -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;
}
+1 -1
View File
@@ -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;
+13 -13
View File
@@ -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/);
});
+1 -1
View 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;
});
});
+1 -1
View File
@@ -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");
+11 -11
View File
@@ -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/);
}
});
+1 -1
View File
@@ -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 = [];
+3 -3
View File
@@ -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()));
});
+2 -2
View File
@@ -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/);
});
+60 -60
View File
@@ -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`);
}
+19 -19
View File
@@ -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);
});
+4 -1
View File
@@ -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"), "");
+129
View File
@@ -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 }));
});
+20 -20
View File
@@ -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);
+4 -4
View File
@@ -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" }));
+3 -3
View File
@@ -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 };
+19
View File
@@ -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 }));
});
+2
View File
@@ -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",
+7 -6
View File
@@ -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;
});
});
+1 -1
View File
@@ -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) => {
+187
View File
@@ -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