Compare commits
7
Commits
org-redact3
...
org-ti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddf4f895cd | ||
|
|
7035ce61b3 | ||
|
|
54bf239e32 | ||
|
|
a4eed1e219 | ||
|
|
1e1bbaa6dd | ||
|
|
78bda9d6a4 | ||
|
|
6b39af7888 |
No files matched your search
@@ -189,8 +189,15 @@ those messages stranded: no later holder receives or reads them, and `agents`
|
||||
lists them under `stranded` (Agent state, below). `message.read` is an explicit acknowledgement. `trail(messageId)` lets
|
||||
an operator inspect content and delivery evidence after an uncertain result.
|
||||
|
||||
Broker socket refusals retain `ok:false`, `error` and any scoped `reason`, and add
|
||||
`rail` and `nextStep` from `src/refusals.mjs`. Guidance is static and never
|
||||
quotes caller input or file bytes. The runner and typed tools carry the guidance
|
||||
to the agent. `src/rails.mjs` in the organization package supplies the shared
|
||||
prompt/live projection; `decisionAutonomy` is the extension point for AU.
|
||||
|
||||
| Verb | Arguments and result |
|
||||
|---|---|
|
||||
| `my_rails` | No args. Read only the current caller's held actions and routes, resolve rights with fixed autonomy levels, network, credential service/scope names and messaging targets. Recomputed from live policy on every call; never returns credential references or values, writes evidence, or raises a decision. Exposed to every managed role as `my_rails`. |
|
||||
| `role.claim` | No args; claims capability's role/run. One current holder per business/instance; a revoked run cannot reclaim that role, including after restart. |
|
||||
| `role.release` | No args; only its current holder releases. |
|
||||
| `role.revoke` | `{role,decision}`; needs an approved decision for this exact target and holder incarnation, routed as REQ-DEC-2 says: the human's for an uncited gated raise, the arbiter's for one citing the current spec. A role whose file lists `role.end` (the chief of staff, row 123, REQ-COS-1) sends `{role,reason}` instead: no decision, a reason of 1 to 500 characters, logged in the `action.allowed` body. Sending both is `invalid-request`; the caller's own run is `role-end-refused`; any other role sending `reason` gets `decision-required`. An instance whose definition is in `END_PROTECTED` (the assistant) is ended or revoked only by the chief of staff or with a decision the human resolved through the CLI (`via` `cli`); anyone else's reason, or a decision an arbiter resolved, is `role-end-refused` (Jason's ruling Q169, row 139). A target whose definition is `THREAD_DEFINITION` (the assistant) has no role claim: the end revokes the current claim of each of its threads, refuses `not-held` when none is held, and returns `{revoked:true,threads:N}` (row 155, #1649). |
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ownRails } from '../../organization/src/rails.mjs';
|
||||
import { refusalGuidance } from './refusals.mjs';
|
||||
import { PROXY_PAGE_VERBS, PROXY_PAGE_CODES } from './proxy-page-vocabulary.mjs';
|
||||
import { randomUUID, randomBytes, createHash } from 'node:crypto';
|
||||
import { validManagementKey, proxyTiming } from '../../model-proxy/src/validation.mjs';
|
||||
@@ -245,10 +247,11 @@ const canonical = (x) =>
|
||||
Number.isFinite(Date.parse(x)) &&
|
||||
new Date(x).toISOString() === x;
|
||||
export class BusError extends Error {
|
||||
// Only explicitly scoped refusals carry a reason; the envelope passes it on.
|
||||
// Guidance is static policy text; never quote arguments or file bytes here.
|
||||
constructor(code, reason) {
|
||||
super(code);
|
||||
this.code = code;
|
||||
Object.assign(this, refusalGuidance(code));
|
||||
if (reason !== undefined) this.reason = reason;
|
||||
}
|
||||
}
|
||||
@@ -6938,6 +6941,13 @@ export class Broker {
|
||||
// Preserve the common request gates and refuse additions until the vocabulary names them.
|
||||
if (verb.startsWith('tasks.') && !PERSON_TASK_VERBS.includes(verb)) fail('unknown-verb');
|
||||
switch (verb) {
|
||||
case 'my_rails': {
|
||||
keys(a, []);
|
||||
this.#agent(s);
|
||||
const entry = b.roles[s.role], definition = b.definitions?.[entry.definition];
|
||||
return ownRails({ instance: s.role, authority: entry.authority, network: entry.network ?? definition?.network,
|
||||
credentials: definition?.credentials ?? [], messaging: definition?.messaging, arbiters: b.arbiters, launcher: b.launch?.by });
|
||||
}
|
||||
case 'role.claim': {
|
||||
keys(a, []);
|
||||
if (s.human) fail('agent-required');
|
||||
|
||||
@@ -16,6 +16,7 @@ export function busOrganization(business, resolved) {
|
||||
!r.limits?.authority
|
||||
)
|
||||
throw new BusError('invalid-resolution');
|
||||
out.roles[role].network = r.limits.network;
|
||||
out.roles[role].authority = structuredClone(r.limits.authority);
|
||||
// REQ-HARN-2: "web" in the resolved tool limit is the grant for the broker's web verbs.
|
||||
out.roles[role].web = (r.limits.tools ?? []).includes('web');
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
// Static refusal table: each listed code maps to one rail and next step. These strings
|
||||
// never interpolate caller arguments, credential references, policy values or file bytes.
|
||||
const entries = [];
|
||||
const group = (codes, rail, nextStep) => {
|
||||
const guidance = Object.freeze({ rail, nextStep });
|
||||
for (const code of codes.split(' ')) {
|
||||
if (entries.some(([known]) => known === code)) throw new Error('duplicate refusal guidance');
|
||||
entries.push([code, guidance]);
|
||||
}
|
||||
};
|
||||
group(
|
||||
"access-refused access-revoked share-exists share-not-covered share-not-found tester-dev-only tester-off tester-refused tester-required tester-target-not-running thread-not-allowed thread-not-owned",
|
||||
"R7: caller access and scoped session boundary",
|
||||
"Use only the caller's own permitted scope. Ask the owner to inspect access or sharing through the supported controls; never borrow another identity."
|
||||
);
|
||||
group(
|
||||
"adapter-failed check-threw duplicate-run malformed-answer no-verdict not-a-merge not-approved not-collected not-human not-merge-option not-resolved not-thread-launch peercred-required reply-limits-unreadable socket-exists store-unreadable target-branch workspace-rename-failed",
|
||||
"R3: fail closed on an unsatisfied operation precondition",
|
||||
"Inspect the supported status and report the refusal code to the responsible owner; do not bypass it or retry an uncertain side effect."
|
||||
);
|
||||
group(
|
||||
"addressee-ambiguous invalid-approval-choice invalid-arbiter invalid-authority invalid-business invalid-choice invalid-class invalid-domain invalid-envelope invalid-harness invalid-messaging invalid-options invalid-plugins invalid-recommendation invalid-request invalid-snapshot invalid-spec-citation invalid-state invalid-task-ref invalid-timestamp name-is-instance name-is-role name-taken project-name-is-repo thread-instance-ambiguous unknown-action unknown-addressee unknown-business unknown-commit unknown-model unknown-plugin unknown-repo unknown-request unknown-requirement unknown-role unknown-run unknown-verb",
|
||||
"R3: validated vocabulary and request shape",
|
||||
"Check the tool schema and permitted identifiers, correct the request, then retry; do not guess around a refusal."
|
||||
);
|
||||
group(
|
||||
"agent-required",
|
||||
"Role/session: agent identity required",
|
||||
"Use this tool only from an authorized managed agent session."
|
||||
);
|
||||
group(
|
||||
"agent-running fire-in-progress instance-fired instance-firing instance-not-listed instance-retired instance-running instance-starting launch-record-mismatch launch-revoked launcher-released not-drained not-drained-run not-exited not-held not-holder not-recipient not-retired revoke-refused revoked role-already-held room-not-open room-thread-name-taken run-ended run-not-ended run-revoked session-clear session-clear-cancelled session-clear-complete session-clearing session-exists session-name-too-long session-not-found session-op-conflict session-retired session-revision-stale thread-already-held thread-exists thread-instance-mismatch thread-instance-missing thread-not-found thread-required thread-required-reply-to-thread-message thread-required-use-thread-send thread-revoked",
|
||||
"Role/session: current holder and lifecycle fence",
|
||||
"Read current session/status and ask the launcher or owner to finish the supported lifecycle transition. A stale or ended run must not reclaim another holder's identity."
|
||||
);
|
||||
group(
|
||||
"apikey-invalid callback-url-invalid harness-off harness-refused key-quota-no-model-key login-state-unknown model-catalogue-unavailable model-harness-incompatible model-key-collision model-provider-unavailable models-ambiguous models-invalid models-unconfirmed models-write-refused priority-invalid priority-unconfirmed priority-write-refused provider-not-allowed proxy-config-invalid proxy-down proxy-first-output-invalid proxy-init-failed proxy-key-invalid proxy-key-issue-failed proxy-key-refused proxy-key-revoking proxy-open proxy-revoke-revision-conflict proxy-rotate-grace-invalid proxy-uncertain thinking-unsupported",
|
||||
"R1/R3: owner-managed model and provider configuration",
|
||||
"Ask the owner to inspect the provider configuration through its supported controls; never put keys or callback contents in messages."
|
||||
);
|
||||
group(
|
||||
"block-recipient block-reply-required block-sender message-recipient-refused message-sender-refused tester-reply-required tester-thread-human user-reply-required user-thread-human",
|
||||
"Role messaging: permitted sender, recipient and reply context",
|
||||
"Read my_rails and use only a permitted target and the required reply context. Ask the owner if the permitted route is unavailable."
|
||||
);
|
||||
group(
|
||||
"command-not-granted credential-file credential-revoke-failed credential-unknown credential-leak gitea-reach-unknown gitea-reach-wider gitea-user-admin gitea-user-disabled gitea-user-foreign gitea-user-gone gitea-user-orphan grant-admin-missing grant-admin-nothing-left grant-admin-rotation-due grant-busy grant-chief-only grant-lost grant-revoke-failed grant-revoke-running grant-token-unknown grants-revoking remote-not-granted remote-host-mismatch remote-not-fork scope-changed",
|
||||
"R1/R8: scoped and revocable access",
|
||||
"Ask the owner to inspect the scoped access and binding state through the supported access controls; do not supply credentials in a call or widen your own grants."
|
||||
);
|
||||
group(
|
||||
"outcome-unknown outcome-unknown-revoked revision-conflict revision-conflict-revoked",
|
||||
"R2/R3: current revision and verified effect",
|
||||
"Read the latest status, revision and receipts before retrying. Verify whether an uncertain effect already happened, and use the current revision for any new attempt."
|
||||
);
|
||||
group(
|
||||
"confirm-required",
|
||||
"R2/R3: explicit confirmation of the current operation",
|
||||
"Read the current status and obtain the required confirmation through the supported human path."
|
||||
);
|
||||
group(
|
||||
"decision-closed decision-consumed decision-mismatch decision-not-approved decision-not-eligible decision-not-found decision-not-held decision-stale supersede-refused",
|
||||
"Role decisions: current, matching, approved and single-use decision",
|
||||
"Read the current decision, its route and its resolution and consumption receipts. Obtain the operation's matching approval from the permitted resolver before acting; a consumed approval cannot be reused."
|
||||
);
|
||||
group(
|
||||
"decision-not-allowed",
|
||||
"Role authority: no free-text decision channel",
|
||||
"Use the role's permitted message path to report the need to a person; do not use another decision channel."
|
||||
);
|
||||
group(
|
||||
"decision-required",
|
||||
"Role authority and R4: approval before acting",
|
||||
"Read my_rails. Raise a decision for the action and its permitted arbiter; hard waits go to a person. Use blocking false when there is no task."
|
||||
);
|
||||
group(
|
||||
"decision-route-refused",
|
||||
"Role messaging: decision close route",
|
||||
"Ask the owner to inspect the raiser and resolver messaging policy; do not route around the refused close path."
|
||||
);
|
||||
group(
|
||||
"definition-not-hireable instance-kept launch-not-configured no-chief-of-staff organization-busy organization-definition-refused organization-instance-referenced organization-instance-retired role-end-refused staff-request-refused",
|
||||
"Role configuration and protected instance controls",
|
||||
"Read the permitted configuration/status and ask the owner or configured chief to repair it through the supported path. Do not edit policy to bypass the refusal."
|
||||
);
|
||||
group(
|
||||
"field-writer task-already-archived task-archived task-conflict task-create-conflict task-create-uncertain task-not-archived task-not-found task-position-full task-project-refused task-scope-required task-subject-required",
|
||||
"Role task ownership, allowed fields and current task state",
|
||||
"Read the current task and assigned role. Use the permitted task operation with current evidence; ask the PM for scope or assignment changes through the decision path."
|
||||
);
|
||||
group(
|
||||
"task-required",
|
||||
"Role decisions: a blocking decision belongs to a task",
|
||||
"Supply the task_ref for this work, or raise with blocking false when there is no task."
|
||||
);
|
||||
group(
|
||||
"fork-foreign gitea-admin-unavailable gitea-failed gitea-not-configured gitea-not-declared host-busy host-changed host-creative-role host-enrolled host-failed host-in-use host-key-mismatch host-line-left host-not-configured host-not-declared host-not-enrolled host-not-granted host-not-ready host-remove-failed host-unknown publish-busy publish-unknown",
|
||||
"Role access: declared service, host or remote and verified outcome",
|
||||
"Check the supported access/status read and ask the owner to repair the declared service or grant. If an effect is uncertain, verify its receipt before retrying."
|
||||
);
|
||||
group(
|
||||
"human-required",
|
||||
"R4/R8: person-only operation",
|
||||
"A person must perform or approve this operation through its supported human path; an agent must not retry as a person."
|
||||
);
|
||||
group(
|
||||
"land-unknown no-push not-a-reviewer ref-moved self-review stale-commit verdict-exists verdict-not-approve verdict-not-found verdict-stale work-not-yours work-ref",
|
||||
"Role review and landing: independent verdict on the current work",
|
||||
"Read the latest work and review receipts. Obtain the required independent review or matching merge decision for the current commit before retrying."
|
||||
);
|
||||
group(
|
||||
"library-fetch-no-grant library-not-configured library-notice-store-failed library-write-no-grant library-write-refused web-failed web-not-configured web-not-granted",
|
||||
"Role tools: granted and configured web/library access",
|
||||
"Read my_rails and the permitted service status. Use only granted tools; ask the owner to repair unavailable service configuration."
|
||||
);
|
||||
group(
|
||||
"message-not-found no-subject not-assistant operation-not-found operation-not-open",
|
||||
"R7/role scope: visible object and allowed operation",
|
||||
"Use the caller's permitted reads to locate current context, then retry only the supported operation. Ask the owner if it remains unavailable."
|
||||
);
|
||||
group(
|
||||
"not-authorized",
|
||||
"Role authority: held action or approved decision",
|
||||
"Read my_rails and use the applicable decision path; ask the owner to inspect grants if no supported path exists."
|
||||
);
|
||||
group(
|
||||
"not-configured project-already-archived project-archived project-exists project-not-archived project-not-configured project-not-found project-record-invalid project-remote-configured project-remote-invalid project-remote-library project-remote-protected project-remote-unknown project-spec-human-only system-type-not-enabled tracker-not-store usage-not-configured",
|
||||
"R3/role scope: configured project and service state",
|
||||
"Read the supported project/service status and ask its owner to repair missing configuration or state before retrying."
|
||||
);
|
||||
group(
|
||||
"not-resolver",
|
||||
"Role resolve right and decision route",
|
||||
"Only the routed resolver holding the matching decision.resolve right may resolve. Read my_rails and notify that resolver."
|
||||
);
|
||||
group(
|
||||
"prd-arbiter-unavailable prd-authority-refused prd-changes-requested prd-decision-closed prd-not-approved prd-self-approval request-access-refused request-closed request-evidence-required request-expired request-failed request-not-configured request-not-found request-not-taken request-origin-refused request-project-refused request-revision-conflict request-selection-refused request-tracker-refused request-verification-refused requirement-snapshot-refused requirement-snapshot-required requirement-snapshot-revoked spec-unconfirmed stale-spec",
|
||||
"Role workflow: current admitted request and approved requirements",
|
||||
"Read the current request, requirements and decision state. Ask the responsible PM or routed arbiter to supply the missing current evidence before continuing."
|
||||
);
|
||||
group(
|
||||
"processing-audit-conflict processing-control-unsafe processing-uninitialized processing-generation-changed processing-halted processing-state-invalid storage-refused storage-unavailable",
|
||||
"R2/R3: available audited storage and processing state",
|
||||
"Stop the refused operation and report the processing or storage state to the owner. Resume only after the supported status shows the condition is repaired."
|
||||
);
|
||||
group(
|
||||
"read-only",
|
||||
"Role/session: read-only capability",
|
||||
"Use the permitted reads. Ask the owner or an authorized writer to perform the change."
|
||||
);
|
||||
group(
|
||||
"reserved-event",
|
||||
"R2: trusted append-only evidence",
|
||||
"Use the typed operation that records its own evidence; never emit a reserved event directly."
|
||||
);
|
||||
group(
|
||||
"role-not-chief",
|
||||
"R9: only the configured chief manages hires",
|
||||
"Send the staffing need to the configured chief through an allowed message path."
|
||||
);
|
||||
group(
|
||||
"unauthenticated",
|
||||
"R7: authenticated caller identity",
|
||||
"Ask the launcher to restore the caller's own authenticated session; never borrow another capability."
|
||||
);
|
||||
group(
|
||||
"request-timeout request-too-large incomplete-request invalid-response response-too-large unknown-tool tool-failed capacity-full",
|
||||
"R3/role tools: bounded protocol and available capacity",
|
||||
"Check the tool schema and narrow oversized requests. For a timeout, inspect status and receipts before retrying; never assume an uncertain operation did not happen."
|
||||
);
|
||||
// Finite error vocabularies forwarded by the project's, library's and request's
|
||||
// typed adapters are part of the same table, not an unhelpful unknown-code path.
|
||||
group(
|
||||
"project-busy library-sync-busy",
|
||||
"Role storage: exclusive write or sync already in progress",
|
||||
"Wait for the current operation to finish, read its status, then retry once the lock is clear."
|
||||
);
|
||||
group(
|
||||
"stale-base library-accept-stale",
|
||||
"R2/R3: current file or served revision",
|
||||
"Read the latest file or library status and its current revision. Reconcile the intended change before retrying with that base; never overwrite a newer change blindly."
|
||||
);
|
||||
group(
|
||||
"citation-cross-universe citation-invalid citation-missing citation-unavailable citation-unfetched citations-over-limit playground-cited-as-canon reply-invalid",
|
||||
"Role replies: validated citations, provenance and scope",
|
||||
"Use permitted library, spec or web reads and supply a valid reply with current citations in the allowed universe and scope. Narrow oversized citation lists; never invent provenance."
|
||||
);
|
||||
group(
|
||||
"invalid-request-admission invalid-request-effect invalid-request-event invalid-request-plan invalid-request-projects artifact-path-invalid artifact-type-refused",
|
||||
"R3: typed request plan, delivery and evidence schema",
|
||||
"Check the current request's tool schema and correct the plan, artifact path or evidence. Delivery requires the supported self-contained HTML artifact and matching verification evidence."
|
||||
);
|
||||
group(
|
||||
"prd-admission-revoked prd-author-invalid prd-candidate-invalid prd-candidate-required prd-candidate-size prd-candidate-stale prd-requirement-invalid",
|
||||
"Role PRD workflow: current candidate, authors and admission",
|
||||
"Read the current request, candidate, requirements, mode and approval. Ask the PM to correct or submit the current candidate through the supported workflow; never approve your own authorship."
|
||||
);
|
||||
group(
|
||||
"request-question-open request-question-closed request-question-mismatch request-phase-refused request-attachment-mismatch request-delivery-required request-document-refused",
|
||||
"Role request workflow: current phase, question and selected attachment",
|
||||
"Read intake_status and follow its current question, wait or delivery step. A pending question needs its answer before another question; use only the operation and attachment valid for the current phase."
|
||||
);
|
||||
group(
|
||||
"request-effect-conflict request-effect-unprepared request-mode-revision-conflict request-operation-conflict request-plan-binding-refused request-plan-path-collision request-plan-path-refused request-project-collision",
|
||||
"R2/R3: current request revision and bound operation",
|
||||
"Read the current request status, plan and operation receipts. Recover an existing operation before retrying; use a new operation ID only for a new intended operation, and never bypass a path or project collision."
|
||||
);
|
||||
group(
|
||||
"request-notification-unavailable request-services-unavailable library-credential-missing library-human-missing library-mapping-invalid library-problem-code library-remote-invalid library-state-invalid library-diverged library-accept-invalid",
|
||||
"R1/R3: owner-configured service, mapping and accepted library state",
|
||||
"Read the supported service or library status and ask the owner to repair the missing binding, mapping or accepted state. Check existing operation receipts before retrying an uncertain effect."
|
||||
);
|
||||
group(
|
||||
"library-commit library-git library-push-failed project-commit-uncertain project-init-cleanup",
|
||||
"R2/R3: verified repository effect and recovery",
|
||||
"Read current repository status and receipts to determine what already happened. Report an uncertain commit, push or cleanup to the owner before any new write; do not retry an uncertain effect blindly."
|
||||
);
|
||||
group(
|
||||
"project-author project-dirty project-git project-id project-invalid project-no-commit project-not-declared project-not-empty project-path project-read-commit project-read-content project-read-missing project-read-path project-record-path project-records-conflict project-records-missing project-remote project-repository tester-project-area",
|
||||
"Role project scope: declared repository, clean state and allowed paths",
|
||||
"Read the permitted project status and file metadata. Correct the declared project or allowed path when possible; ask the owner to repair repository state or identity. Preserve existing changes and never bypass repository or path checks."
|
||||
);
|
||||
group(
|
||||
"project-record-unchanged",
|
||||
"R2: no new record effect for unchanged content",
|
||||
"Read the current record and its existing commit receipt. If it already matches the intended content, use that evidence instead of retrying the same write."
|
||||
);
|
||||
group(
|
||||
"library-write-story-path library-revert-not-agent",
|
||||
"Role library writes: supporting paths and permitted agent-owned commits",
|
||||
"Use only the supporting paths and agent-owned commit reversals permitted by the library tool. Preserve story and canon files; ask the owner when the requested edit is outside that scope."
|
||||
);
|
||||
group(
|
||||
"query-argument-invalid query-chapter-ambiguous query-item-too-large query-universe-unknown",
|
||||
"Role library reads: known universe, unambiguous selection and bounded result",
|
||||
"Read the permitted library index, choose a known universe and an unambiguous book or chapter, and narrow the query or requested range before retrying."
|
||||
);
|
||||
export const REFUSALS = Object.freeze(Object.fromEntries(entries));
|
||||
const UNKNOWN = Object.freeze({ rail: "R3: fail closed", nextStep: "Report the refusal code to the responsible owner and inspect supported status; do not bypass the refusal." });
|
||||
export const refusalGuidance = code => Object.hasOwn(REFUSALS, code) ? REFUSALS[code] : UNKNOWN;
|
||||
@@ -9,11 +9,12 @@ const LIMIT = 65536;
|
||||
const PROJECT_SAVE_AT_START = new RegExp(`"verb":"${PROJECT_SAVE_VERB.replaceAll('.', '\\.')}"`);
|
||||
const LIBRARY_SAVE_AT_START = /"verb":"library\.file\.save"/;
|
||||
const SAVE_VERB_AT_START = { test: text => PROJECT_SAVE_AT_START.test(text) || LIBRARY_SAVE_AT_START.test(text) };
|
||||
// The refusal envelope. C2-06 (sage-3599): `reason` is added only when the error carries one, which
|
||||
// only explicitly scoped refusals do, so every other envelope is byte-identical to before.
|
||||
const refusal = (e, other) => e instanceof BusError
|
||||
? { ok: false, error: e.code, ...(typeof e.reason === 'string' ? { reason: e.reason } : {}) }
|
||||
: { ok: false, error: other };
|
||||
// Preserve every code and scoped reason, adding only static rail/next-step guidance.
|
||||
const refusal = (e, other) => {
|
||||
const error = e instanceof BusError ? e : new BusError(other);
|
||||
return { ok: false, error: error.code, rail: error.rail, nextStep: error.nextStep,
|
||||
...(typeof error.reason === 'string' ? { reason: error.reason } : {}) };
|
||||
};
|
||||
// One request per connection. There is deliberately no reconnect/retry or SQL verb.
|
||||
// `tasks` ({handle, timeout}) is the trusted S3 adapter; only the eight task verbs reach it.
|
||||
// `web` is the trusted web service (web.mjs), or a function from a business id to one (or null); only
|
||||
@@ -48,16 +49,16 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
|
||||
socket.end(JSON.stringify(result) + '\n');
|
||||
};
|
||||
socket.setTimeout(timeout, () => {
|
||||
finish({ ok: false, error: 'request-timeout' });
|
||||
finish(refusal(new BusError('request-timeout')));
|
||||
socket.destroySoon();
|
||||
});
|
||||
socket.on('data', async (chunk) => {
|
||||
if (done) return;
|
||||
if (processing) return finish({ ok: false, error: 'invalid-envelope' });
|
||||
if (processing) return finish(refusal(new BusError('invalid-envelope')));
|
||||
const total = input.length + chunk.length;
|
||||
if (total > LIMIT && !(total <= PROJECT_SAVE_REQUEST_MAX &&
|
||||
SAVE_VERB_AT_START.test(Buffer.concat([input, chunk]).subarray(0, 1024).toString('latin1')))) {
|
||||
finish({ ok: false, error: 'request-too-large' });
|
||||
finish(refusal(new BusError('request-too-large')));
|
||||
return;
|
||||
}
|
||||
input = Buffer.concat([input, chunk]);
|
||||
@@ -187,7 +188,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
|
||||
}
|
||||
});
|
||||
socket.on('end', () => {
|
||||
if (!done && !processing) finish({ ok: false, error: 'incomplete-request' });
|
||||
if (!done && !processing) finish(refusal(new BusError('incomplete-request')));
|
||||
});
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { connect } from 'node:net';
|
||||
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { Client } from '../src/client.mjs';
|
||||
import { refusalGuidance } from '../src/refusals.mjs';
|
||||
import { serve } from '../src/server.mjs';
|
||||
import { FIXED_TEXTS, GuardError } from '../../library/src/guard.mjs';
|
||||
import { git } from '../../library/tests/helpers.mjs';
|
||||
@@ -138,7 +139,7 @@ test('C2-06 page store is session-local, caps 20 fetches and 200 KiB, drops at d
|
||||
await assert.rejects(f.call(replacement, 'message.send', { to: 'human', body: citedBody([{ url: 'https://fixture.invalid/21', quote: 'xxxx' }]) }), { code: 'citation-unfetched' });
|
||||
});
|
||||
|
||||
test('C2-06 transport passes safe capped guard reason; ordinary refusal envelope is byte-identical', async t => {
|
||||
test('C2-06 transport passes safe capped guard reason; ordinary refusal retains its code and adds static rail guidance', async t => {
|
||||
const f = citationWorld(t, { guard: async () => { throw new GuardError('citation-invalid', 'guard reason '.repeat(50)); } });
|
||||
f.call(f.agent, 'role.claim'); const path = join(f.root, 'socket');
|
||||
const server = await serve({ broker: f.broker, path }); t.after(() => server.close());
|
||||
@@ -148,7 +149,7 @@ test('C2-06 transport passes safe capped guard reason; ordinary refusal envelope
|
||||
const socket = connect(path); let text = ''; socket.on('error', reject); socket.on('data', b => { text += b; }); socket.on('end', () => resolve(text));
|
||||
socket.on('connect', () => socket.write(JSON.stringify({ cap: f.agent, verb: 'unknown-fixture' }) + '\n'));
|
||||
});
|
||||
assert.equal(raw, '{"ok":false,"error":"unknown-verb"}\n');
|
||||
assert.deepEqual(JSON.parse(raw), { ok: false, error: 'unknown-verb', ...refusalGuidance('unknown-verb') });
|
||||
});
|
||||
|
||||
test('C2-06 non-library businesses retain role.claim shape and unguarded free sends', async t => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Row 288: broker authority, exact notice and strict Console revert API. Fixture grant rows only.
|
||||
import { refusalGuidance } from '../src/refusals.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
||||
@@ -180,7 +181,7 @@ test('C2-02 socket: a save name nested in proof cannot enlarge an agent envelope
|
||||
socket.on('error', reject); socket.on('connect', () => socket.write(request));
|
||||
socket.on('data', bytes => { result += bytes; if (result.includes('\n')) { socket.destroy(); resolve(JSON.parse(result)); } });
|
||||
});
|
||||
assert.deepEqual(response, { ok: false, error: 'request-too-large' });
|
||||
assert.deepEqual(response, { ok: false, error: 'request-too-large', ...refusalGuidance('request-too-large') });
|
||||
assert.equal(f.seen.length, 0);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { join } from 'node:path';
|
||||
import { fixture, git } from './spec-fixtures.mjs';
|
||||
import { serve } from '../src/server.mjs';
|
||||
import { Client } from '../src/client.mjs';
|
||||
import { refusalGuidance } from '../src/refusals.mjs';
|
||||
import { PROJECT_FILE_VERBS } from '../src/broker.mjs';
|
||||
// Row 148 (#1642): the human-only verbs behind the Console's spec and docs editor.
|
||||
const refusals = f => f.store.all("SELECT body FROM events WHERE kind='action.refused'").map(r => JSON.parse(r.body).code);
|
||||
@@ -109,11 +110,11 @@ test('a request that names the save verb first and another verb last is refused
|
||||
const f = await socketFixture(t);
|
||||
const pad = 'x'.repeat(100000);
|
||||
const sneaky = `{"human":{"fixture":true},"verb":"project.file.save","verb":"agents","args":{"pad":"${pad}"}}\n`;
|
||||
assert.deepEqual(await raw(f.path, sneaky), { ok: false, error: 'request-too-large' });
|
||||
assert.deepEqual(await raw(f.path, sneaky), { ok: false, error: 'request-too-large', ...refusalGuidance('request-too-large') });
|
||||
const unmarked = await raw(f.path, JSON.stringify({ human: { fixture: true }, verb: 'agents', args: { pad } }) + '\n');
|
||||
assert.deepEqual(unmarked, { ok: false, error: 'request-too-large' });
|
||||
assert.deepEqual(unmarked, { ok: false, error: 'request-too-large', ...refusalGuidance('request-too-large') });
|
||||
const over = await raw(f.path, JSON.stringify({ human: { fixture: true }, verb: 'project.file.save', args: { pad: 'x'.repeat(1024 * 1024 + 80000) } }) + '\n');
|
||||
assert.deepEqual(over, { ok: false, error: 'request-too-large' });
|
||||
assert.deepEqual(over, { ok: false, error: 'request-too-large', ...refusalGuidance('request-too-large') });
|
||||
});
|
||||
|
||||
test('two saves racing for one repository make one commit', async t => {
|
||||
|
||||
@@ -13,6 +13,8 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
|
||||
const source = readFileSync(new URL('../src/broker.mjs', import.meta.url), 'utf8');
|
||||
assert.deepEqual([...source.matchAll(/^import .*;$/gm)].map(m => m[0]), [
|
||||
// Rows330/331: pure verb/refusal vocabulary, never the host management implementation.
|
||||
"import { ownRails } from '../../organization/src/rails.mjs';",
|
||||
"import { refusalGuidance } from './refusals.mjs';",
|
||||
"import { PROXY_PAGE_VERBS, PROXY_PAGE_CODES } from './proxy-page-vocabulary.mjs';",
|
||||
// Row 210 (#1707): createHash gives host.run events the argv, stdin and output sha256 values.
|
||||
"import { randomUUID, randomBytes, createHash } from 'node:crypto';",
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { connect } from 'node:net';
|
||||
import { PROXY_PAGE_CODES } from '../src/proxy-page-vocabulary.mjs';
|
||||
import { Broker, BusError } from '../src/broker.mjs';
|
||||
import { REFUSALS, refusalGuidance } from '../src/refusals.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { busOrganization } from '../src/organization-context.mjs';
|
||||
import { serve } from '../src/server.mjs';
|
||||
import { Client } from '../src/client.mjs';
|
||||
import { resolveInstance, validateBusinessDocument } from '../../organization/src/index.mjs';
|
||||
import { businessDoc, REPO_ROLES, systemFor } from '../../organization/tests/helpers.mjs';
|
||||
import { railsText } from '../../organization/src/rails.mjs';
|
||||
import { GUARD_CODES } from '../../library/src/reply-protocol.mjs';
|
||||
const SECRET = 'sk-test-NOT-A-SECRET-1234';
|
||||
function fixture(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'rails-'));
|
||||
const business = validateBusinessDocument(businessDoc(root), join(root, 'acme.json'), { rolesDir: REPO_ROLES });
|
||||
const resolve = b => Object.fromEntries(Object.keys(b.roles).map(instance => [instance, resolveInstance({ system: systemFor(root), business: b, instance })]));
|
||||
writeFileSync(business.roles.coder.credentials.gitea.file, SECRET);
|
||||
const resolved = resolve(business), store = new Store(root);
|
||||
const broker = new Broker({ store, businesses: { acme: busOrganization(business, resolved) } });
|
||||
const cap = broker.bindLaunch({ business: 'acme', role: 'coder', run: 'coder-run', harness: 'pi' });
|
||||
broker.request(cap, { verb: 'role.claim' });
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
return { root, business, resolved, resolve, broker, store, cap };
|
||||
}
|
||||
|
||||
test('my_rails equals the resolved prompt content, reads live narrowing, and writes/raises nothing', t => {
|
||||
const f = fixture(t), call = args => f.broker.request(f.cap, { verb: 'my_rails', args });
|
||||
const before = f.store.get('SELECT count(*) n FROM events').n;
|
||||
assert.deepEqual(call({}), f.resolved.coder.rails);
|
||||
const result = call({}); result.actions.length = 0;
|
||||
assert.notEqual(call({}).actions.length, 0, 'returned rails do not mutate policy');
|
||||
const business = structuredClone(f.business);
|
||||
business.roles.coder.vars['limits.authority'] = ['message.send'];
|
||||
const narrowed = f.resolve(business);
|
||||
f.broker.reloadBusiness('acme', busOrganization(business, narrowed));
|
||||
assert.deepEqual(call({}), narrowed.coder.rails);
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM events').n, before);
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, 0);
|
||||
});
|
||||
|
||||
test('my_rails cannot select another identity or expose credential refs and file/call values', t => {
|
||||
const f = fixture(t);
|
||||
for (const args of [{ agent: 'cto' }, { role: 'cto' }, { instance: 'cto' }, { business: 'other' }, { token: SECRET }]) {
|
||||
assert.throws(() => f.broker.request(f.cap, { verb: 'my_rails', args }), { code: 'invalid-request' });
|
||||
}
|
||||
const rails = f.broker.request(f.cap, { verb: 'my_rails' });
|
||||
const text = JSON.stringify(rails) + railsText(rails);
|
||||
for (const value of [SECRET, 'placeholder-not-a-token', f.resolved.coder.credentials.gitea.file, f.resolved.cto.credentials.gitea.file]) assert.ok(!text.includes(value), value);
|
||||
const human = f.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
assert.throws(() => f.broker.request(human, { verb: 'my_rails' }), { code: 'agent-required' });
|
||||
});
|
||||
|
||||
function raw(path, request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = connect(path); let out = '';
|
||||
socket.on('error', reject);
|
||||
socket.on('connect', () => socket.end(JSON.stringify(request) + '\n'));
|
||||
socket.on('data', b => out += b);
|
||||
socket.on('end', () => resolve(JSON.parse(out)));
|
||||
});
|
||||
}
|
||||
test('cross-role refusal envelope names its rail and next step without changing code or outcome', async t => {
|
||||
const f = fixture(t), path = join(f.root, 'bus.sock');
|
||||
const server = await serve({ broker: f.broker, path }); t.after(() => server.close());
|
||||
const request = { cap: f.cap, verb: 'decision.resolve', args: { id: 'missing', choice: SECRET } };
|
||||
const refused = await raw(path, request);
|
||||
assert.equal(refused.ok, false); assert.equal(refused.error, 'decision-not-found');
|
||||
assert.deepEqual({ rail: refused.rail, nextStep: refused.nextStep }, REFUSALS['decision-not-found']);
|
||||
assert.ok(!JSON.stringify(refused).includes(SECRET));
|
||||
await assert.rejects(new Client({ path, cap: f.cap }).call('decision.resolve', request.args), e => {
|
||||
assert.equal(e.code, 'decision-not-found'); assert.equal(e.nextStep, refused.nextStep); return true;
|
||||
});
|
||||
const decision = f.broker.request(f.cap, { verb: 'decision.raise', args: { action: 'task.scope.change', domain: 'delivery',
|
||||
question: 'Scope?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'no', blocking: false } });
|
||||
const cross = await raw(path, { cap: f.cap, verb: 'decision.resolve', args: { id: decision.id, choice: 'yes' } });
|
||||
assert.equal(cross.error, 'not-resolver'); assert.match(cross.rail, /resolve right/); assert.match(cross.nextStep, /routed resolver/);
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decision_events WHERE decision=?', decision.id).n, 0);
|
||||
// Exercise an actual cross-role action without its required decision, not just resolve.
|
||||
const live = busOrganization(f.business, f.resolved);
|
||||
live.roles.coder.authority.crossRole.push('role.end');
|
||||
f.broker.reloadBusiness('acme', live);
|
||||
const decisions = f.store.get('SELECT count(*) n FROM decisions').n;
|
||||
const required = await raw(path, { cap: f.cap, verb: 'role.revoke', args: { role: 'reviewer', reason: SECRET } });
|
||||
assert.equal(required.ok, false); assert.equal(required.error, 'decision-required');
|
||||
assert.deepEqual({ rail: required.rail, nextStep: required.nextStep }, REFUSALS['decision-required']);
|
||||
assert.ok(!JSON.stringify(required).includes(SECRET));
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, decisions);
|
||||
});
|
||||
|
||||
test('every literal fail branch in broker has static guidance; guidance never contains payload values', () => {
|
||||
const source = readFileSync(new URL('../src/broker.mjs', import.meta.url), 'utf8');
|
||||
// Balance each call (including ternaries), so a following statement cannot masquerade
|
||||
// as a fail code. Every literal branch, with either quote style, must be in the table.
|
||||
for (const match of source.matchAll(/\bfail\s*\(/g)) {
|
||||
let depth = 1, i = match.index + match[0].length;
|
||||
while (depth && i < source.length) {
|
||||
const char = source[i++];
|
||||
if (char === "'" || char === '"') {
|
||||
const start = i - 1;
|
||||
let value = '';
|
||||
while (i < source.length && source[i] !== char) {
|
||||
if (source[i] === "\\") { i += 2; continue; }
|
||||
value += source[i++];
|
||||
}
|
||||
i++;
|
||||
if (/^[a-z][a-z0-9-]*$/.test(value) && (value.includes('-') || !source.slice(match.index + match[0].length, start).trim())) assert.ok(Object.hasOwn(REFUSALS, value), value);
|
||||
} else if (char === '(') depth++;
|
||||
else if (char === ')') depth--;
|
||||
}
|
||||
assert.equal(depth, 0, 'balanced fail call');
|
||||
}
|
||||
// Finite delegated fail(code) sources must be covered as well as literal branches.
|
||||
for (const code of [...PROXY_PAGE_CODES, ...GUARD_CODES, 'project-busy', 'remote-host-mismatch', 'remote-not-fork',
|
||||
'processing-control-unsafe', 'processing-uninitialized', 'processing-generation-changed']) {
|
||||
assert.ok(Object.hasOwn(REFUSALS, code), code);
|
||||
}
|
||||
for (const [code, guidance] of Object.entries(REFUSALS)) {
|
||||
assert.ok(guidance.rail.length > 0 && guidance.nextStep.length > 0, code);
|
||||
assert.deepEqual({ rail: new BusError(code).rail, nextStep: new BusError(code).nextStep }, guidance);
|
||||
assert.ok(!JSON.stringify(guidance).includes(SECRET));
|
||||
}
|
||||
assert.deepEqual(refusalGuidance(SECRET), refusalGuidance('unknown-external-code'));
|
||||
});
|
||||
|
||||
test('finite forwarded project, library and request errors have static next steps', () => {
|
||||
const modules = [
|
||||
...['index', 'git', 'paths'].map(name => new URL(`../../project/src/${name}.mjs`, import.meta.url)),
|
||||
...readdirSync(new URL('../../library/src/', import.meta.url)).filter(f => f.endsWith('.mjs'))
|
||||
.map(name => new URL(`../../library/src/${name}`, import.meta.url)),
|
||||
...['request-store', 'request-lifecycle'].map(name => new URL(`../src/${name}.mjs`, import.meta.url)),
|
||||
];
|
||||
for (const file of modules) {
|
||||
const source = readFileSync(file, 'utf8');
|
||||
for (const match of source.matchAll(/\b(?:new\s+(?:ProjectError|LibraryError|RequestError|GuardError)|raise|refuse|fail)\s*\(\s*['"]([a-z][a-z0-9-]*)['"]/g)) {
|
||||
assert.ok(Object.hasOwn(REFUSALS, match[1]), `${file.pathname}: ${match[1]}`);
|
||||
}
|
||||
}
|
||||
assert.doesNotMatch(REFUSALS['outcome-unknown'].nextStep, /confirmation|human/);
|
||||
assert.doesNotMatch(REFUSALS['revision-conflict'].nextStep, /confirmation|human/);
|
||||
assert.match(REFUSALS['task-required'].nextStep, /task_ref.*blocking false/);
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { Store } from '../src/store.mjs';
|
||||
import { Broker, TASK_VERBS } from '../src/broker.mjs';
|
||||
import { serve } from '../src/server.mjs';
|
||||
import { Client } from '../src/client.mjs';
|
||||
import { refusalGuidance } from '../src/refusals.mjs';
|
||||
import { startBroker } from '../src/runtime.mjs';
|
||||
import { readProcess, verifyHuman } from '../src/human.mjs';
|
||||
import { verifyTester, testerNonce } from '../src/tester.mjs';
|
||||
@@ -387,9 +388,9 @@ test('an envelope carries exactly one of cap, human and tester; no tester authen
|
||||
const cap = b.bindReader({ business: 'demo' });
|
||||
const role = b.bindLaunch({ business: 'demo', role: 'pm', run: 'pm-run', harness: 'pi' });
|
||||
for (const r of [{ tester: p, cap }, { tester: p, cap: role }, { tester: p, human: p }, { tester: p, human: p, cap }])
|
||||
assert.deepEqual(await raw(path, { ...r, verb: 'agents' }), { ok: false, error: 'invalid-envelope' }, Object.keys(r).join('+'));
|
||||
assert.deepEqual(await raw(path, { ...r, verb: 'agents' }), { ok: false, error: 'invalid-envelope', ...refusalGuidance('invalid-envelope') }, Object.keys(r).join('+'));
|
||||
assert.equal(calls, 0);
|
||||
assert.deepEqual(await raw(bare, { tester: p, verb: 'agents' }), { ok: false, error: 'tester-required' });
|
||||
assert.deepEqual(await raw(bare, { tester: p, verb: 'agents' }), { ok: false, error: 'tester-required', ...refusalGuidance('tester-required') });
|
||||
assert.deepEqual(await raw(path, { tester: p, verb: 'agents' }), { ok: true, result: [] });
|
||||
assert.equal(calls, 1);
|
||||
// The tester's capability lasts one request, as the human's does.
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Store } from '../src/store.mjs';
|
||||
import { Broker } from '../src/broker.mjs';
|
||||
import { serve } from '../src/server.mjs';
|
||||
import { Client } from '../src/client.mjs';
|
||||
import { refusalGuidance } from '../src/refusals.mjs';
|
||||
import { views } from '../src/views.mjs';
|
||||
const businesses = {
|
||||
demo: {
|
||||
@@ -72,7 +73,10 @@ test('malformed, oversized and identity-forging envelopes refuse without echoing
|
||||
const out = await raw(input);
|
||||
assert.equal(JSON.parse(out).ok, false);
|
||||
assert.ok(!out.includes('not-json'));
|
||||
assert.ok(out.length < 150);
|
||||
const refusal = JSON.parse(out);
|
||||
assert.ok(['invalid-envelope', 'request-too-large'].includes(refusal.error));
|
||||
assert.deepEqual(refusal, { ok: false, error: refusal.error, ...refusalGuidance(refusal.error) });
|
||||
assert.ok(out.length < 1024); // A bounded static description, independent of input size.
|
||||
}
|
||||
});
|
||||
test('client preserves UTF-8 when a response divides a multibyte character', async (t) => {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// Old path of packages/organization/src/agent-levels-write.mjs (rename design r2, stage B).
|
||||
export * from "../../organization/src/agent-levels-write.mjs";
|
||||
@@ -0,0 +1,2 @@
|
||||
// Compatibility path for the organization package's caller-rails projection.
|
||||
export * from "../../organization/src/rails.mjs";
|
||||
@@ -1 +1 @@
|
||||
{"business":"acme","contract":"<ROOT>/roles/coder.md","credentials":{"gitea":{"file":"<ROOT>/secrets/coder-gitea.token","rotateBy":"2099-01-01","service":"gitea"},"vikunja":{"expires":"2099-01-01","file":"<ROOT>/secrets/coder-vikunja.token","service":"vikunja"}},"definition":"coder","holder":null,"instance":"coder","launch":null,"limits":{"authority":{"crossRole":["task.scope.change"],"withinRole":["task.update.assigned","git.push.working","review.request","message.send","record.write"]},"network":"none","tools":["read","write","edit","bash","grep","find","ls"]},"project":"stack","provenance":{"bus.tester":"default","dataRoot":"system","environment":"system","execution.adapter":"system","execution.backend":"system","execution.model":"system","execution.provider":"system","gitea.baseUrl":"business:acme","harness":"business:acme:roles.coder","limits.network":["business:acme:roles.coder"],"tracker.baseUrl":"business:acme","tracker.descendants":"default","tracker.kind":"default","tracker.pollSeconds":"business:acme","tracker.project":"project:stack","tracker.reconcileMinutes":"default"},"skills":["ms-unslop","ms-sdlc","ms-file-read","ms-file-write"],"tracker":{"bot":"bot-acme-coder","botId":5},"vars":{"bus.tester":false,"dataRoot":"<ROOT>/data","environment":"development","execution.adapter":"mock","execution.backend":"docker","execution.model":"glm-5.3-flash","execution.provider":"zai","gitea.baseUrl":"https://git.example","harness":"pi","limits.network":"none","tracker.baseUrl":"http://127.0.0.1:3456","tracker.descendants":false,"tracker.kind":"vikunja","tracker.pollSeconds":60,"tracker.project":3,"tracker.reconcileMinutes":60},"webSource":null}
|
||||
{"business":"acme","contract":"<ROOT>/roles/coder.md","credentials":{"gitea":{"file":"<ROOT>/secrets/coder-gitea.token","rotateBy":"2099-01-01","service":"gitea"},"vikunja":{"expires":"2099-01-01","file":"<ROOT>/secrets/coder-vikunja.token","service":"vikunja"}},"definition":"coder","holder":null,"instance":"coder","launch":null,"limits":{"authority":{"crossRole":["task.scope.change"],"withinRole":["task.update.assigned","git.push.working","review.request","message.send","record.write"]},"network":"none","tools":["read","write","edit","bash","grep","find","ls"]},"project":"stack","provenance":{"bus.tester":"default","dataRoot":"system","environment":"system","execution.adapter":"system","execution.backend":"system","execution.model":"system","execution.provider":"system","gitea.baseUrl":"business:acme","harness":"business:acme:roles.coder","limits.network":["business:acme:roles.coder"],"tracker.baseUrl":"business:acme","tracker.descendants":"default","tracker.kind":"default","tracker.pollSeconds":"business:acme","tracker.project":"project:stack","tracker.reconcileMinutes":"default"},"rails":{"actions":[{"action":"task.update.assigned","routes":[{"to":"alone"}]},{"action":"git.push.working","routes":[{"to":"alone"}]},{"action":"review.request","routes":[{"to":"alone"}]},{"action":"message.send","routes":[{"to":"alone"}]},{"action":"record.write","routes":[{"to":"alone"}]},{"action":"task.scope.change","routes":[{"domain":"delivery","to":"pm"},{"domain":"technical","to":"cto"}]}],"credentials":[{"scopes":["write:issue","write:repository","read:user"],"service":"gitea"},{"scopes":{"projects":["views_buckets_tasks"],"tasks":["read_one","update"],"tasks_comments":["create"]},"service":"vikunja"}],"messaging":null,"network":"none","resolve":[]},"skills":["ms-unslop","ms-sdlc","ms-file-read","ms-file-write"],"tracker":{"bot":"bot-acme-coder","botId":5},"vars":{"bus.tester":false,"dataRoot":"<ROOT>/data","environment":"development","execution.adapter":"mock","execution.backend":"docker","execution.model":"glm-5.3-flash","execution.provider":"zai","gitea.baseUrl":"https://git.example","harness":"pi","limits.network":"none","tracker.baseUrl":"http://127.0.0.1:3456","tracker.descendants":false,"tracker.kind":"vikunja","tracker.pollSeconds":60,"tracker.project":3,"tracker.reconcileMinutes":60},"webSource":null}
|
||||
@@ -67,6 +67,91 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"rails": {
|
||||
"actions": [
|
||||
{
|
||||
"action": "task.update.assigned",
|
||||
"routes": [
|
||||
{
|
||||
"to": "alone"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"action": "git.push.working",
|
||||
"routes": [
|
||||
{
|
||||
"to": "alone"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"action": "review.request",
|
||||
"routes": [
|
||||
{
|
||||
"to": "alone"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"action": "message.send",
|
||||
"routes": [
|
||||
{
|
||||
"to": "alone"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"action": "record.write",
|
||||
"routes": [
|
||||
{
|
||||
"to": "alone"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"action": "task.scope.change",
|
||||
"routes": [
|
||||
{
|
||||
"domain": "delivery",
|
||||
"to": "pm"
|
||||
},
|
||||
{
|
||||
"domain": "technical",
|
||||
"to": "cto"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"resolve": [],
|
||||
"network": "none",
|
||||
"credentials": [
|
||||
{
|
||||
"service": "gitea",
|
||||
"scopes": [
|
||||
"write:issue",
|
||||
"write:repository",
|
||||
"read:user"
|
||||
]
|
||||
},
|
||||
{
|
||||
"service": "vikunja",
|
||||
"scopes": {
|
||||
"tasks": [
|
||||
"read_one",
|
||||
"update"
|
||||
],
|
||||
"tasks_comments": [
|
||||
"create"
|
||||
],
|
||||
"projects": [
|
||||
"views_buckets_tasks"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"messaging": null
|
||||
},
|
||||
"credentials": {
|
||||
"gitea": {
|
||||
"service": "gitea",
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"business":"acme","contract":"/synthetic/roles/coder.md","credentials":{},"definition":"coder","holder":"example","instance":"coder","launch":null,"limits":{"authority":{"crossRole":[],"withinRole":["message.send"]},"network":"none","tools":["read","write"]},"project":"stack","provenance":{"bus.tester":"default","dataRoot":"system","environment":"system","execution.adapter":"system","execution.backend":"system","execution.model":"system","execution.provider":"system","limits.network":["business:acme:roles.coder"],"model":"business:acme:roles.coder","tracker.baseUrl":"business:acme","tracker.descendants":"default","tracker.kind":"default","tracker.pollSeconds":"project:stack:roles.coder","tracker.project":"project:stack","tracker.reconcileMinutes":"default"},"skills":[],"tracker":null,"vars":{"bus.tester":false,"dataRoot":"/synthetic/data","environment":"development","execution.adapter":"mock","execution.backend":"docker","execution.model":"system-model","execution.provider":"zai","limits.network":"none","model":"agent-model","tracker.baseUrl":"http://127.0.0.1:3456","tracker.descendants":false,"tracker.kind":"vikunja","tracker.pollSeconds":15,"tracker.project":3,"tracker.reconcileMinutes":60},"webSource":null}
|
||||
{"business":"acme","contract":"/synthetic/roles/coder.md","credentials":{},"definition":"coder","holder":"example","instance":"coder","launch":null,"limits":{"authority":{"crossRole":[],"withinRole":["message.send"]},"network":"none","tools":["read","write"]},"project":"stack","provenance":{"bus.tester":"default","dataRoot":"system","environment":"system","execution.adapter":"system","execution.backend":"system","execution.model":"system","execution.provider":"system","limits.network":["business:acme:roles.coder"],"model":"business:acme:roles.coder","tracker.baseUrl":"business:acme","tracker.descendants":"default","tracker.kind":"default","tracker.pollSeconds":"project:stack:roles.coder","tracker.project":"project:stack","tracker.reconcileMinutes":"default"},"rails":{"actions":[{"action":"message.send","routes":[{"to":"alone"}]}],"credentials":[],"messaging":null,"network":"none","resolve":[]},"skills":[],"tracker":null,"vars":{"bus.tester":false,"dataRoot":"/synthetic/data","environment":"development","execution.adapter":"mock","execution.backend":"docker","execution.model":"system-model","execution.provider":"zai","limits.network":"none","model":"agent-model","tracker.baseUrl":"http://127.0.0.1:3456","tracker.descendants":false,"tracker.kind":"vikunja","tracker.pollSeconds":15,"tracker.project":3,"tracker.reconcileMinutes":60},"webSource":null}
|
||||
+17
-1
@@ -54,10 +54,26 @@
|
||||
"crossRole": []
|
||||
}
|
||||
},
|
||||
"rails": {
|
||||
"actions": [
|
||||
{
|
||||
"action": "message.send",
|
||||
"routes": [
|
||||
{
|
||||
"to": "alone"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"resolve": [],
|
||||
"network": "none",
|
||||
"credentials": [],
|
||||
"messaging": null
|
||||
},
|
||||
"credentials": {},
|
||||
"tracker": null,
|
||||
"launch": null,
|
||||
"webSource": null,
|
||||
"skills": [],
|
||||
"digest": "975144f5fcd6989c3393175b1713908c632c04f378b5f28a95cb2f4e552fbefa"
|
||||
"digest": "5a8df768f2359fd8c0131eb5e8ce8d427f68d6d465ba20d42b413a280a922ef0"
|
||||
}
|
||||
@@ -117,7 +117,7 @@ test('installed wrapper pins v1 validate and resolve output, preimage and digest
|
||||
'installed-resolve.json': normalized(resolved),
|
||||
'installed-preimage.json': normalized(canonicalJson(preimage)) + '\n',
|
||||
};
|
||||
assert.equal(sha256(snapshots['installed-preimage.json'].slice(0, -1)), '5fc9d6258f4725b0e260d99e5cf81058a05d21b8ff298bb53653efbbc8013da9');
|
||||
assert.equal(sha256(snapshots['installed-preimage.json'].slice(0, -1)), '0a12b35f55d033b91bd89745859028d252be52b05ca4083964b778a27ec20fd6');
|
||||
for (const [name, actual] of Object.entries(snapshots)) {
|
||||
assert.equal(actual, fixture(name), name);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
// #1873 Phase A slice A2: the owner's terminal verbs for the root and project agent levels (design
|
||||
// docs/plans/2026-11-10_agent-levels-design.md sections 6, 7 and 9 item 1).
|
||||
//
|
||||
// mosaic agents root list [--json]
|
||||
// mosaic agents root set NAME [--definition ROLE] [--var KEY=VALUE]... [--var-json KEY=JSON]...
|
||||
// [--unset-var KEY]... [--exclude-orgs ID,ID | --exclude-orgs none] [--revision R]
|
||||
// mosaic agents root remove NAME [--revision R]
|
||||
// mosaic project agents PROJECT --org ID list [--json]
|
||||
// mosaic project agents PROJECT --org ID set NAME --definition ROLE [--var KEY=VALUE]... [--var-json KEY=JSON]... [--revision R]
|
||||
// mosaic project agents PROJECT --org ID narrow NAME --var KEY=VALUE... [--var-json KEY=JSON]... [--revision R]
|
||||
// mosaic project agents PROJECT --org ID exclude NAME [--revision R]
|
||||
// mosaic project agents PROJECT --org ID remove NAME [--revision R]
|
||||
//
|
||||
// Every write is the owner's: an agent run is refused before anything is read (exit 3), and the
|
||||
// verbs run only on a named dev instance, as `mosaic org` does, with the local dev owner as actor.
|
||||
// Without --revision a write reads the revision just before it writes, so a concurrent change
|
||||
// between that read and the write still refuses, but one made before the command started does
|
||||
// not. Pass --revision from a `list` to make the write conditional on what was listed.
|
||||
// A write prints its receipt; refusals name keys, never values.
|
||||
|
||||
import { configDir } from '../../organization/src/organization-config.mjs';
|
||||
import { OrgError } from '../../organization/src/org-registry.mjs';
|
||||
import { OrganizationError, organizationWriter } from '../../organization/src/organization.mjs';
|
||||
import { changeRootAgent, patchRootEntry, projectAgentSummary, readRootAgentsFile, rootAgentSummary } from '../../organization/src/agent-levels-write.mjs';
|
||||
import { loadSystem, rolesDir } from './config.mjs';
|
||||
import { DEV_OWNER, devOwnerInstance } from './dev-local-owner.mjs';
|
||||
import { CliError } from './errors.mjs';
|
||||
import { refuseInsideAgent } from './transport.mjs';
|
||||
|
||||
export const AGENTS_ROOT_USAGE = 'mosaic agents root list [--json] | set NAME [--definition ROLE] [--var KEY=VALUE]... [--var-json KEY=JSON]... [--unset-var KEY]... [--exclude-orgs ID,ID|none] [--revision R] | remove NAME [--revision R]';
|
||||
export const PROJECT_AGENTS_USAGE = 'mosaic project agents PROJECT --org ID list [--json] | set NAME --definition ROLE [--var KEY=VALUE]... [--revision R] | narrow NAME --var KEY=VALUE... [--revision R] | exclude NAME [--revision R] | remove NAME [--revision R]';
|
||||
|
||||
const usage = (text, line) => new CliError(`${text}\nusage: ${line}`, 4);
|
||||
const REPEATED = ['--var', '--var-json', '--unset-var'];
|
||||
const SINGLE = ['--definition', '--exclude-orgs', '--revision', '--org'];
|
||||
|
||||
// Option values are never echoed back: a refusal names the option only.
|
||||
function parse(args, line) {
|
||||
const positional = [], single = {}, repeated = { '--var': [], '--var-json': [], '--unset-var': [] }, flags = new Set();
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a === '--json') { if (flags.has(a)) throw usage('duplicate --json', line); flags.add(a); }
|
||||
else if (REPEATED.includes(a) || SINGLE.includes(a)) {
|
||||
if (i + 1 >= args.length) throw usage(`${a} needs one value`, line);
|
||||
const value = args[++i];
|
||||
if (REPEATED.includes(a)) repeated[a].push(value);
|
||||
else if (Object.hasOwn(single, a)) throw usage(`duplicate ${a}`, line);
|
||||
else single[a] = value;
|
||||
} else if (a.startsWith('--')) throw usage('unknown option', line);
|
||||
else positional.push(a);
|
||||
}
|
||||
return { positional, single, repeated, flags };
|
||||
}
|
||||
|
||||
function only(parsed, allowed, line) {
|
||||
for (const key of [...Object.keys(parsed.single), ...REPEATED.filter(k => parsed.repeated[k].length), ...parsed.flags])
|
||||
if (!allowed.includes(key)) throw usage(`unexpected ${key}`, line);
|
||||
}
|
||||
|
||||
// KEY=VALUE pairs; a --var-json value is parsed as JSON. A malformed pair names its key at most.
|
||||
function vars(parsed, line) {
|
||||
const out = {};
|
||||
const put = (key, value) => {
|
||||
if (!key) throw usage('a var needs KEY=VALUE', line);
|
||||
if (Object.hasOwn(out, key)) throw usage(`var ${key} is given twice`, line);
|
||||
out[key] = value;
|
||||
};
|
||||
for (const pair of parsed.repeated['--var']) {
|
||||
const at = pair.indexOf('=');
|
||||
if (at < 1) throw usage('a var needs KEY=VALUE', line);
|
||||
put(pair.slice(0, at), pair.slice(at + 1));
|
||||
}
|
||||
for (const pair of parsed.repeated['--var-json']) {
|
||||
const at = pair.indexOf('=');
|
||||
if (at < 1) throw usage('a var needs KEY=JSON', line);
|
||||
const key = pair.slice(0, at);
|
||||
try { put(key, JSON.parse(pair.slice(at + 1))); }
|
||||
catch (e) { if (e instanceof CliError) throw e; throw usage(`var ${key} is not valid JSON`, line); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const revisionOf = (parsed, read) => parsed.single['--revision'] ?? read();
|
||||
function owner(io) {
|
||||
refuseInsideAgent(io.env);
|
||||
// Its refusal is one fixed line naming no path.
|
||||
let instance;
|
||||
try { instance = devOwnerInstance(io.env); } catch (error) { throw error instanceof CliError ? error : new CliError(String(error?.message), 2); }
|
||||
// The instance's system config must exist and validate, marker included, as `mosaic org` checks:
|
||||
// a missing or broken config refuses (exit 3) before anything is read or written.
|
||||
loadSystem({ env: io.env, instance });
|
||||
return { dir: configDir(io.env), roles: rolesDir(io.env) };
|
||||
}
|
||||
// Only the writers' own coded refusals print their text (it names keys and ids, never a value).
|
||||
// Anything else, a filesystem error above all, prints its code and a fixed line: its message can
|
||||
// hold a host path. So does a coded refusal whose text happens to hold an absolute path.
|
||||
const ABSOLUTE_PATH = /(?:^|[\s('"=:])\/[^\s/]/;
|
||||
function refusal(error) {
|
||||
if (error instanceof CliError) return error;
|
||||
const code = typeof error?.code === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(error.code) ? error.code : null;
|
||||
const ours = (error instanceof OrgError || error instanceof OrganizationError || error?.name === 'BusinessError') && code !== null;
|
||||
const text = ours && typeof error.message === 'string' && !ABSOLUTE_PATH.test(error.message) ? error.message : 'the operation failed; the error text is not shown';
|
||||
// A refusal after the replace says the change landed; the receipt may be missing.
|
||||
const landed = error?.committed ? ' (the change was written; its receipt may be missing)' : '';
|
||||
return new CliError(`${code ? `${code}: ` : ''}${text}${landed}`, 2);
|
||||
}
|
||||
|
||||
export async function agentsRootCommand(args, io) {
|
||||
const line = AGENTS_ROOT_USAGE;
|
||||
const parsed = parse(args, line);
|
||||
const [op, name, ...extra] = parsed.positional;
|
||||
try {
|
||||
const { dir, roles } = owner(io);
|
||||
if (op === 'list') {
|
||||
if (name !== undefined) throw usage('list takes no name', line);
|
||||
only(parsed, ['--json'], line);
|
||||
const read = await readRootAgentsFile(dir);
|
||||
const listing = { revision: read.revision, state: read.exists ? 'present' : 'absent', agents: rootAgentSummary(read.document) };
|
||||
io.stdout.write(parsed.flags.has('--json') ? `${JSON.stringify(listing, null, 2)}\n` : formatRoot(listing));
|
||||
return;
|
||||
}
|
||||
if (!['set', 'remove'].includes(op) || !name || extra.length) throw usage('root takes list, set NAME or remove NAME', line);
|
||||
only(parsed, op === 'set' ? ['--definition', '--var', '--var-json', '--unset-var', '--exclude-orgs', '--revision'] : ['--revision'], line);
|
||||
let entry = null;
|
||||
if (op === 'set') {
|
||||
const patch = {};
|
||||
if (Object.hasOwn(parsed.single, '--definition')) patch.definition = parsed.single['--definition'];
|
||||
const set = vars(parsed, line);
|
||||
for (const key of parsed.repeated['--unset-var']) {
|
||||
if (Object.hasOwn(set, key)) throw usage(`var ${key} is both set and unset`, line);
|
||||
set[key] = null;
|
||||
}
|
||||
if (Object.keys(set).length) patch.vars = set;
|
||||
if (Object.hasOwn(parsed.single, '--exclude-orgs')) {
|
||||
const text = parsed.single['--exclude-orgs'];
|
||||
patch.excludeOrgs = text === 'none' ? [] : text.split(',');
|
||||
}
|
||||
if (!Object.keys(patch).length) throw usage('set needs --definition, a var or --exclude-orgs', line);
|
||||
entry = old => patchRootEntry(old, patch);
|
||||
}
|
||||
const revision = await revisionOf(parsed, async () => (await readRootAgentsFile(dir)).revision);
|
||||
const receipt = await changeRootAgent(dir, { revision, name, entry }, { actor: DEV_OWNER, owner: DEV_OWNER, rolesDir: roles });
|
||||
io.stdout.write(`${JSON.stringify(receipt)}\n`);
|
||||
} catch (error) { throw refusal(error); }
|
||||
}
|
||||
|
||||
export async function projectAgentsCommand(args, io) {
|
||||
const line = PROJECT_AGENTS_USAGE;
|
||||
const parsed = parse(args, line);
|
||||
const [project, op, name, ...extra] = parsed.positional;
|
||||
try {
|
||||
const { dir, roles } = owner(io);
|
||||
const org = parsed.single['--org'];
|
||||
if (!project || !org) throw usage('project agents needs PROJECT and --org ID', line);
|
||||
const writer = organizationWriter({ dir, rolesDir: roles });
|
||||
if (op === 'list') {
|
||||
if (name !== undefined) throw usage('list takes no name', line);
|
||||
only(parsed, ['--org', '--json'], line);
|
||||
const { document, revision } = writer.read(org);
|
||||
if (!Object.hasOwn(document.projects ?? {}, project)) throw new OrgError('project-not-found', `project ${project} is not in organization ${org}`);
|
||||
const listing = { organization: org, project, revision, agents: projectAgentSummary(document.projects[project].agents ?? {}) };
|
||||
io.stdout.write(parsed.flags.has('--json') ? `${JSON.stringify(listing, null, 2)}\n` : formatProject(listing));
|
||||
return;
|
||||
}
|
||||
if (!['set', 'narrow', 'exclude', 'remove'].includes(op) || !name || extra.length) throw usage('project agents takes list, set, narrow, exclude or remove NAME', line);
|
||||
const allowed = { set: ['--definition', '--var', '--var-json'], narrow: ['--var', '--var-json'], exclude: [], remove: [] }[op];
|
||||
only(parsed, ['--org', '--revision', ...allowed], line);
|
||||
let entry = null;
|
||||
if (op === 'set') {
|
||||
if (!Object.hasOwn(parsed.single, '--definition')) throw usage('set needs --definition', line);
|
||||
const v = vars(parsed, line);
|
||||
entry = { definition: parsed.single['--definition'], ...(Object.keys(v).length ? { vars: v } : {}) };
|
||||
} else if (op === 'narrow') {
|
||||
const v = vars(parsed, line);
|
||||
if (!Object.keys(v).length) throw usage('narrow needs at least one --var', line);
|
||||
entry = { vars: v };
|
||||
} else if (op === 'exclude') entry = { exclude: true };
|
||||
const revisionBefore = revisionOf(parsed, () => writer.read(org).revision);
|
||||
const result = writer.setProjectAgent(org, { revision: revisionBefore, project, name, entry }, DEV_OWNER.sub);
|
||||
// The receipt: names and revisions only. The writer's `previous` is a host path; it isn't printed.
|
||||
const receipt = { verb: 'project.agents.set', organization: org, project, agent: name, form: op === 'set' ? 'define' : op, revisionBefore, revisionAfter: result.revision, transaction: result.transaction };
|
||||
io.stdout.write(`${JSON.stringify(receipt)}\n`);
|
||||
} catch (error) { throw refusal(error); }
|
||||
}
|
||||
|
||||
function formatRoot({ revision, state, agents }) {
|
||||
if (!agents.length) return `root agents: none (${state === 'absent' ? 'no file' : 'empty'}), revision ${revision}\n`;
|
||||
return `root agents (revision ${revision}):\n` + agents.map(a =>
|
||||
` ${a.name}: ${a.definition}${a.model ? `, model ${a.model}` : ''}${a.varKeys.length ? `, vars ${a.varKeys.join(', ')}` : ''}${a.excludeOrgs.length ? `, not in ${a.excludeOrgs.join(', ')}` : ''}\n`).join('');
|
||||
}
|
||||
function formatProject({ organization, project, revision, agents }) {
|
||||
if (!agents.length) return `project ${project} in ${organization}: no project agent entries, revision ${revision}\n`;
|
||||
return `project ${project} in ${organization} (revision ${revision}):\n` + agents.map(a =>
|
||||
` ${a.name}: ${a.form}${a.definition ? ` ${a.definition}` : ''}${a.varKeys?.length ? `, vars ${a.varKeys.join(', ')}` : ''}\n`).join('');
|
||||
}
|
||||
@@ -48,6 +48,7 @@ import { consoleRestart, consoleStart, consoleStatus, consoleStop } from "./org-
|
||||
|
||||
import { usageCommand, USAGE_USAGE } from "./usage.mjs";
|
||||
import { orgCommand, ORG_USAGE } from "./org.mjs";
|
||||
import { agentsRootCommand, AGENTS_ROOT_USAGE, projectAgentsCommand, PROJECT_AGENTS_USAGE } from "./agent-levels.mjs";
|
||||
import { ORG_CONFIG, runConfigCommand } from "../../organization/src/config-command.mjs";
|
||||
import { devCommand, DEV_USAGE, fixtureLaunchEnv, fixtureLauncherOptions } from "./dev-fixture.mjs";
|
||||
import { runCommand, RUN_USAGE } from "./run-evidence.mjs";
|
||||
@@ -65,6 +66,8 @@ export const USAGE = `usage:
|
||||
${ORG_INSTANCES_USAGE}
|
||||
mosaic business validate|resolve|instances|instance|veto: the old spelling of mosaic org config validate|resolve and mosaic org instances|instance|veto, same arguments
|
||||
${PROJECT_USAGE}
|
||||
${PROJECT_AGENTS_USAGE}
|
||||
${AGENTS_ROOT_USAGE}
|
||||
${PROJECT_BUS_USAGE}
|
||||
${NORTH_STAR_USAGE}
|
||||
${INTERVIEW_USAGE}
|
||||
@@ -731,6 +734,9 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
|
||||
if (verb === 'org' && rest[0] === 'config') return orgConfig(rest.slice(1), io);
|
||||
if (verb === 'org' && ["instances", "instance", "veto"].includes(rest[0])) return orgInstancesCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
if (verb === 'org') return orgCommand(rest, io, deps);
|
||||
// A2 (#1873): the owner's root and project agent levels. `mosaic agents` without `root` stays the bus read.
|
||||
if (verb === "agents" && rest[0] === "root") return agentsRootCommand(rest.slice(1), io);
|
||||
if (verb === "project" && rest[0] === "agents") return projectAgentsCommand(rest.slice(1), io);
|
||||
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
|
||||
if (verb === "project" && rest[0] === "north-star") return northStarCommand(rest.slice(1), io, (p) => callFor(p, io, deps));
|
||||
if (verb === "project" && rest[0] === "interview") return interviewCommand(rest.slice(1), io);
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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"`);
|
||||
|
||||
@@ -369,8 +369,8 @@ test("a launched pm's request_staff reaches the chief of staff; no other session
|
||||
const bad = await call("pm", `TOOL request_staff ${JSON.stringify({ task_ref: "vikunja:1/2", roles: [{ role: "coder", count: 0 }] })}`);
|
||||
assert.match(bad.body, /invalid-request/);
|
||||
// The chief of staff has no request_staff, and a pm has no launch.
|
||||
assert.equal((await call("chief", 'TOOL request_staff {"task_ref":"vikunja:1/2","roles":[{"role":"coder","count":1}]}')).body, "refused: unknown-tool");
|
||||
assert.equal((await call("pm", 'TOOL launch {"instance":"coder"}')).body, "refused: unknown-tool");
|
||||
assert.equal((await call("chief", 'TOOL request_staff {"task_ref":"vikunja:1/2","roles":[{"role":"coder","count":1}]}')).body.split("\n")[0], "refused: unknown-tool");
|
||||
assert.equal((await call("pm", 'TOOL launch {"instance":"coder"}')).body.split("\n")[0], "refused: unknown-tool");
|
||||
// Nothing was launched by asking: the chief of staff's limits are the same, and sonnet has one place left.
|
||||
assert.deepEqual(readSessions(f.dataRoot).map((s) => s.instance).sort(), ["chief", "pm"]);
|
||||
const launched = await call("chief", 'TOOL launch {"instance":"coder"}');
|
||||
|
||||
@@ -867,7 +867,7 @@ test("row 194 D3 item 5: the chief's session hires with its tool and launches it
|
||||
assert.equal(listed.launched, true);
|
||||
// Hire vars set only model and limits.* (N3). harness and thinking are agent-layer route vars the
|
||||
// human sets, so until then the launch refuses no-route.
|
||||
assert.equal(await ask("launch", { instance: "sec-2" }), "refused: no-route");
|
||||
assert.equal((await ask("launch", { instance: "sec-2" })).split("\n")[0], "refused: no-route");
|
||||
const route = { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" };
|
||||
s.call("instances.set", { revision: s.call("instances").revision, instance: "sec-2", op: "change", vars: route });
|
||||
const launched = JSON.parse(await ask("launch", { instance: "sec-2" }));
|
||||
@@ -875,13 +875,13 @@ test("row 194 D3 item 5: the chief's session hires with its tool and launches it
|
||||
assert.deepEqual(s.running("sec-2").map((x) => x.run), [launched.run]);
|
||||
assert.equal(readSessions(p.f.dataRoot).find((x) => x.run === launched.run).launchedBy, "chief");
|
||||
// The schema's pattern guides the model. The host refuses any name its file doesn't list.
|
||||
assert.equal(await ask("launch", { instance: "NOT A NAME" }), "refused: instance-not-listed");
|
||||
assert.equal(await ask("launch", { instance: "reviewer" }), "refused: instance-not-listed");
|
||||
assert.equal((await ask("launch", { instance: "NOT A NAME" })).split("\n")[0], "refused: instance-not-listed");
|
||||
assert.equal((await ask("launch", { instance: "reviewer" })).split("\n")[0], "refused: instance-not-listed");
|
||||
await s.claimed(launched.run);
|
||||
const fired = JSON.parse(await ask("fire", { name: "sec-2", reason: "the review is done" }));
|
||||
assert.ok(fired.transaction, JSON.stringify(fired));
|
||||
await until(() => s.running("sec-2").length === 0, 30000);
|
||||
// A fired instance stays in roles, off the listing, so the chief can't launch it again.
|
||||
assert.equal(s.call("instances").instances["sec-2"].launched, false);
|
||||
assert.equal(await ask("launch", { instance: "sec-2" }), "refused: instance-not-listed");
|
||||
assert.equal((await ask("launch", { instance: "sec-2" })).split("\n")[0], "refused: instance-not-listed");
|
||||
});
|
||||
@@ -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"));
|
||||
|
||||
@@ -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";
|
||||
@@ -281,7 +282,7 @@ function prompt({ contract, resolved, session, tools, untyped, builtins, persona
|
||||
...checkoutLines(session.checkouts ?? []),
|
||||
// Row 89 (#1583): the names the harness gives its built-ins, as the manifest lists them.
|
||||
`- Built-in tools: ${builtins.join(", ") || "none"}.`,
|
||||
`- Bus tools: ${tools.map((t) => t.name).join(", ")}. The broker decides every call; a refusal names its reason.`,
|
||||
`- Bus tools: ${tools.map((t) => t.name).join(", ")}. The broker decides every call; a refusal carries its code, the rail it enforces and the next step (plus a reason when available).`,
|
||||
"- Each message to you arrives as one turn. Your final answer goes back to the sender as the reply, so end each turn with the answer itself.",
|
||||
"- You never hold a token. You act on the tracker, the forge and other roles only through the bus tools.",
|
||||
];
|
||||
@@ -306,6 +307,7 @@ function prompt({ contract, resolved, session, tools, untyped, builtins, persona
|
||||
if (named.has("request_staff"))
|
||||
lines.push("- Staff: you don't launch sessions. For intake or a task, request_staff takes exactly one of request or task_ref and names each role with how many. The chief of staff answers by message with the instances it launched, or with the refusal and its reason.");
|
||||
if (untyped.length) lines.push(`- Actions you hold that have no bus tool yet: ${untyped.join(", ")}. Say so instead of working around it.`);
|
||||
lines.push("", railsText(resolved.rails ?? ownRails({ instance: resolved.instance, authority: resolved.limits.authority, network: resolved.limits.network })), "");
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
// capability or the claim stopped working (ended, revoked), 23 the broker
|
||||
// stayed unreachable, 2 a bad run directory.
|
||||
|
||||
import { refusalGuidance } from "../../bus/src/refusals.mjs";
|
||||
import { spawn } from "node:child_process";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { chmodSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, closeSync } from "node:fs";
|
||||
@@ -366,6 +367,7 @@ export function toolServer({ path, tools, client, local }) {
|
||||
answer({ ok: true, result });
|
||||
} catch (e) {
|
||||
answer({ ok: false, error: /^[a-z-]{1,64}$/.test(e.code ?? "") ? e.code : "tool-failed",
|
||||
...refusalGuidance(e.code),
|
||||
...(typeof e.reason === "string" && request.tool === "advance_request" && tool.verb === "request.advance" && e.code === "request-evidence-required"
|
||||
? { reason: e.reason.slice(0, 300) } : tool.verb === "record.write" && e.code === "project-record-invalid" && typeof e.reason === "string"
|
||||
? { reason: e.reason.slice(0, 300) } : {}) });
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
// socket (MOSAIC_TOOL_SOCKET) with `{tool, args}`; the runner checks the
|
||||
// name against this list and forwards the call with its own capability.
|
||||
|
||||
import { refusalGuidance } from "../../bus/src/refusals.mjs";
|
||||
import { connect } from "node:net";
|
||||
import { ACTIONS, ID_PATTERN, THREAD_DEFINITION, CREATIVE_DEFINITIONS } from "../../organization/src/vocabulary.mjs";
|
||||
|
||||
@@ -372,10 +373,11 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project
|
||||
requirement_snapshot: str("Exact approved snapshot ID; omit to read the active snapshot"),
|
||||
}));
|
||||
}
|
||||
add("my_rails", "my_rails", null, "Read only your current actions, routes, resolve levels, network, credential service/scope names and messaging limits. No values or other agents' rails.", obj({}));
|
||||
// mos-1308 / M3: no generic spec, evidence, tracker, decision or write tools. The
|
||||
// C2-08 web grant still supplies broker web reads to companion and research only.
|
||||
if (CREATIVE_DEFINITIONS.includes(resolved.definition))
|
||||
return tools.filter(tool => tool.name === "send_message" || tool.name.startsWith("library_") ||
|
||||
return tools.filter(tool => tool.name === "my_rails" || tool.name === "send_message" || tool.name.startsWith("library_") ||
|
||||
tool.name === "web_search" || tool.name === "web_fetch");
|
||||
return tools;
|
||||
}
|
||||
@@ -429,7 +431,13 @@ export function callTool(path, tool, args = {}, { timeout = 60_000 } = {}) {
|
||||
if (!input.includes("\n")) return;
|
||||
try {
|
||||
const r = JSON.parse(input);
|
||||
r.ok ? end(null, r.result) : end(new Error(tool === "intake_question" && r.error === "outcome-unknown" ? "outcome-unknown" : tool === "launch" && r.error === "instance-running" ? "refused: instance-running: target instance is already running and will receive its waiting messages when it claims; you must not launch it again or relay the notice" : `refused: ${r.error}${typeof r.reason === "string" && tool === "advance_request" && r.error === "request-evidence-required" ? `: ${r.reason.slice(0, 300)}` : tool === "write_record" && r.error === "project-record-invalid" && typeof r.reason === "string" ? `: ${r.reason.slice(0, 300)}` : ""}`));
|
||||
if (r.ok) return end(null, r.result);
|
||||
const guidance = refusalGuidance(r.error);
|
||||
const error = new Error(tool === "intake_question" && r.error === "outcome-unknown" ? "outcome-unknown" : tool === "launch" && r.error === "instance-running" ? "refused: instance-running: target instance is already running and will receive its waiting messages when it claims; you must not launch it again or relay the notice" : `refused: ${r.error}${typeof r.reason === "string" && tool === "advance_request" && r.error === "request-evidence-required" ? `: ${r.reason.slice(0, 300)}` : tool === "write_record" && r.error === "project-record-invalid" && typeof r.reason === "string" ? `: ${r.reason.slice(0, 300)}` : ""}`);
|
||||
error.code = r.error;
|
||||
Object.assign(error, guidance);
|
||||
error.message += `\nRail: ${guidance.rail}\nNext step: ${guidance.nextStep}`;
|
||||
end(error);
|
||||
} catch {
|
||||
end(unknown("tool reply isn't JSON"));
|
||||
}
|
||||
|
||||
@@ -200,7 +200,7 @@ test("a chief-of-staff bundle lists end_run, change_organization and set_name as
|
||||
const r = resolvedFor(t, "chief", (d) => (d.roles.chief.vars = { harness, model: "claude-opus-5-5" }));
|
||||
const b = build(t, r);
|
||||
const prefix = harness === "pi" ? "" : "mcp__mosaic__";
|
||||
assert.deepEqual(b.manifest.typed, ["send_message", "launch", "end_run", "hire", "fire", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"].map((n) => prefix + n), harness);
|
||||
assert.deepEqual(b.manifest.typed, ["send_message", "launch", "end_run", "hire", "fire", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "my_rails"].map((n) => prefix + n), harness);
|
||||
assert.deepEqual(b.manifest.untyped, ["decision.resolve.technical", "decision.resolve.delivery"], harness);
|
||||
assert.match(readFileSync(b.files.prompt, "utf8"), /Bus tools: send_message, launch, end_run, hire, fire, organization_revision, change_organization, set_name, read_spec, write_record, raise_decision, /);
|
||||
assert.match(readFileSync(b.files.prompt, "utf8"), /Actions you hold that have no bus tool yet/);
|
||||
|
||||
@@ -14,9 +14,9 @@ const role = (t, definition, harness = 'pi') => resolvedFor(t, 'bean', d => {
|
||||
d.roles.bean = { definition, credentials: { gitea: { ...d.roles.coder.credentials.gitea } }, vars: { harness, model: 'claude-sonnet-5-5', thinking: 'high' } };
|
||||
});
|
||||
const expected = definition => ['send_message', ...(['companion', 'research'].includes(definition)
|
||||
? ['web_search', 'web_fetch'] : []), ...LOOKUPS, 'library_doc_write', 'library_doc_delete'];
|
||||
? ['web_search', 'web_fetch'] : []), ...LOOKUPS, 'library_doc_write', 'library_doc_delete', 'my_rails'];
|
||||
|
||||
test('M3: per-role snapshots contain eight names, or ten with the C2-08 web grant', t => {
|
||||
test('M3/TI: per-role snapshots contain nine names, or eleven with the C2-08 web grant', t => {
|
||||
for (const definition of CREATIVE) {
|
||||
const tools = typedTools({ resolved: role(t, definition), library: true, tracker: true, arbiter: true });
|
||||
assert.deepEqual(tools.map(t => t.name), expected(definition), definition);
|
||||
@@ -72,7 +72,7 @@ test('bundle flag reaches both harness tool manifests and creative prompt withou
|
||||
|
||||
test('C2-05c scope schemas expand only find/search/get, keeping manuscript and tool names unchanged', t => {
|
||||
const tools = typedTools({ resolved: role(t, 'ideas'), library: true });
|
||||
assert.deepEqual(tools.map(t => t.name), ['send_message', ...LOOKUPS, 'library_doc_write', 'library_doc_delete']);
|
||||
assert.deepEqual(tools.map(t => t.name), ['send_message', ...LOOKUPS, 'library_doc_write', 'library_doc_delete', 'my_rails']);
|
||||
for (const name of ['library_find', 'library_search', 'library_get']) {
|
||||
const tool = tools.find(t => t.name === name);
|
||||
assert.deepEqual(tool.parameters.properties.scope.enum, ['canon', 'playground', 'supporting']);
|
||||
|
||||
@@ -11,12 +11,12 @@ const role = (t, definition) => resolvedFor(t, 'bean', doc => {
|
||||
doc.roles.bean = { definition, credentials: { gitea: { ...doc.roles.coder.credentials.gitea } }, vars: { harness: 'pi', model: 'claude-sonnet-5-5', thinking: 'high' } };
|
||||
});
|
||||
|
||||
test('C2-02 M3: each creative role has eight tools, or ten with its web grant', t => {
|
||||
test('C2-02 M3/TI: each creative role has nine tools, or eleven with its web grant', t => {
|
||||
for (const definition of CREATIVE) {
|
||||
const resolved = role(t, definition), tools = typedTools({ resolved, library: true, tracker: true, arbiter: true });
|
||||
const expected = ['send_message', ...(['companion', 'research'].includes(definition) ? ['web_search', 'web_fetch'] : []), ...LOOKUPS, ...WRITES];
|
||||
const expected = ['send_message', ...(['companion', 'research'].includes(definition) ? ['web_search', 'web_fetch'] : []), ...LOOKUPS, ...WRITES, 'my_rails'];
|
||||
assert.deepEqual(tools.map(tool => tool.name), expected, definition);
|
||||
assert.equal(tools.length, ['companion', 'research'].includes(definition) ? 10 : 8);
|
||||
assert.equal(tools.length, ['companion', 'research'].includes(definition) ? 11 : 9);
|
||||
assert.equal(untypedActions(resolved, tools).includes('library.write'), false);
|
||||
}
|
||||
});
|
||||
@@ -56,5 +56,5 @@ test('C2-02 tools: no library, noncreative or no within-role write grant exposes
|
||||
const narrowed = structuredClone(creative);
|
||||
narrowed.limits.authority.withinRole = narrowed.limits.authority.withinRole.filter(action => action !== 'library.write');
|
||||
narrowed.limits.authority.crossRole.push('library.write');
|
||||
assert.deepEqual(typedTools({ resolved: narrowed, library: true }).map(tool => tool.name), ['send_message', ...LOOKUPS]);
|
||||
assert.deepEqual(typedTools({ resolved: narrowed, library: true }).map(tool => tool.name), ['send_message', ...LOOKUPS, 'my_rails']);
|
||||
});
|
||||
@@ -68,7 +68,7 @@ test("tools/call goes through the tool socket; a refusal is an isError result",
|
||||
assert.deepEqual(JSON.parse(ok.content[0].text), { tool: "list_agents", args: { scope: "all" } });
|
||||
const refused = (await server.send("tools/call", { name: "launch", arguments: { instance: "coder" } })).result;
|
||||
assert.equal(refused.isError, true);
|
||||
assert.equal(refused.content[0].text, "refused: capacity-full");
|
||||
assert.match(refused.content[0].text, /^refused: capacity-full\nRail: .+\nNext step: .+/);
|
||||
assert.deepEqual(sock.calls.map((c) => c.tool), ["list_agents", "launch"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { buildBundle } from '../src/bundle.mjs';
|
||||
import { typedTools } from '../src/tools.mjs';
|
||||
import { resolvedFor, scratch, HARNESS, REPO_SKILLS } from './helpers.mjs';
|
||||
import { ownRails, railsText, decisionAutonomy } from '../../organization/src/rails.mjs';
|
||||
|
||||
const SECRET = 'sk-test-NOT-A-SECRET-1234';
|
||||
test('coder prompt lists every held action with its route and only service/scope names', t => {
|
||||
const resolved = resolvedFor(t, 'coder');
|
||||
const root = scratch(t), workspace = join(root, 'ws'); mkdirSync(workspace);
|
||||
const bundle = buildBundle({ dir: join(root, 'bundle'), resolved,
|
||||
contract: '# Contract', harness: HARNESS.pi, skillsDir: REPO_SKILLS,
|
||||
session: { run: 'r1', workspace, toolSocket: join(root, 'tool.sock'), turnMarker: join(root, 'turn.done') } });
|
||||
const prompt = readFileSync(bundle.files.prompt, 'utf8');
|
||||
assert.ok(prompt.includes(railsText(resolved.rails)));
|
||||
for (const action of resolved.limits.authority.withinRole) assert.ok(prompt.includes(`- ${action}: alone.`));
|
||||
assert.match(prompt, /task.scope.change: delivery \(default\): arbiter instance "pm"; technical: arbiter instance "cto"/);
|
||||
assert.match(prompt, /Network: none/);
|
||||
assert.match(prompt, /Credential service gitea; scopes:.*write:repository/);
|
||||
assert.ok(!railsText(resolved.rails).includes(resolved.credentials.gitea.file));
|
||||
assert.ok(!railsText(resolved.rails).includes(SECRET));
|
||||
assert.match(prompt, /a refusal carries its code, the rail it enforces and the next step/);
|
||||
});
|
||||
|
||||
test('all roles including creative roles receive a parameterless caller-only my_rails tool', t => {
|
||||
for (const instance of ['coder', 'chief', 'reviewer']) {
|
||||
const tool = typedTools({ resolved: resolvedFor(t, instance) }).find(t => t.name === 'my_rails');
|
||||
assert.equal(tool.verb, 'my_rails');
|
||||
assert.deepEqual(tool.parameters, { type: 'object', properties: {}, required: [], additionalProperties: false });
|
||||
}
|
||||
const creative = typedTools({ resolved: { ...resolvedFor(t, 'coder'), definition: 'companion' } });
|
||||
assert.ok(creative.some(t => t.name === 'my_rails'));
|
||||
});
|
||||
|
||||
test('resolve rights name their classes and fixed levels through the AU extension point', t => {
|
||||
const { rails } = resolvedFor(t, 'cto');
|
||||
assert.deepEqual(rails.resolve, [{ right: 'decision.resolve.technical', classes: [
|
||||
{ name: 'cross-role', level: decisionAutonomy('cross-role') }, { name: 'merge', level: decisionAutonomy('merge') },
|
||||
] }]);
|
||||
assert.match(railsText(rails), /merge: out of the loop/);
|
||||
});
|
||||
|
||||
test('fixed domains, arbiter self-raises and hard waits describe existing routes only', () => {
|
||||
const rails = ownRails({ instance: 'cto', authority: { withinRole: ['deploy'],
|
||||
crossRole: ['git.merge.protected', 'policy.change'] }, arbiters: { technical: 'cto', delivery: 'pm' } });
|
||||
assert.deepEqual(rails.actions, [
|
||||
{ action: 'deploy', routes: [{ to: 'human' }] },
|
||||
{ action: 'git.merge.protected', routes: [{ domain: 'technical', to: 'human' }] },
|
||||
{ action: 'policy.change', routes: [{ domain: 'delivery', to: 'pm' }] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('hire and fire are unavailable to a chief that is not the configured launcher', t => {
|
||||
const { rails } = resolvedFor(t, 'chief', doc => { delete doc.launch; });
|
||||
for (const action of ['role.hire', 'role.fire']) {
|
||||
assert.deepEqual(rails.actions.find(a => a.action === action).routes, [{ to: 'unavailable' }]);
|
||||
}
|
||||
assert.match(railsText(rails), /role.hire: unavailable here; only the configured chief can hire or fire/);
|
||||
});
|
||||
@@ -392,7 +392,12 @@ test('advance_request exposes the named task evidence refusal to the PM tool', a
|
||||
const { Client } = await import('../../bus/src/client.mjs');
|
||||
const server = await runner.toolServer({ path, tools: [{ name: 'advance_request', verb: 'request.advance' }, { name: 'other_tool', verb: 'request.advance' }], local: {}, client: new Client({ path: busPath, cap: 'fixture' }) });
|
||||
t.after(() => new Promise(resolve => server.close(resolve)));
|
||||
await assert.rejects(callTool(path, 'advance_request', {}), { message: `refused: request-evidence-required: ${reason.slice(0, 300)}` });
|
||||
await assert.rejects(callTool(path, 'advance_request', { other: true }), { message: 'refused: project-record-invalid' });
|
||||
await assert.rejects(callTool(path, 'other_tool', {}), { message: 'refused: request-evidence-required' });
|
||||
const refusal = message => error => {
|
||||
assert.equal(error.message.split('\n')[0], message);
|
||||
assert.match(error.message, /\nRail: .+\nNext step: .+/);
|
||||
return true;
|
||||
};
|
||||
await assert.rejects(callTool(path, 'advance_request', {}), refusal(`refused: request-evidence-required: ${reason.slice(0, 300)}`));
|
||||
await assert.rejects(callTool(path, 'advance_request', { other: true }), refusal('refused: project-record-invalid'));
|
||||
await assert.rejects(callTool(path, 'other_tool', {}), refusal('refused: request-evidence-required'));
|
||||
});
|
||||
@@ -477,10 +477,23 @@ test("typed tools carry the runner's capability; launch goes to the host's launc
|
||||
ctx.call(ctx.pm, "message.send", { to: "coder", body: 'TOOL resolve_decision {"id":"x"}' });
|
||||
ctx.call(ctx.pm, "message.send", { to: "coder", body: 'TOOL send_message {"to":"nobody","body":"x"}' });
|
||||
await until(() => replies(ctx)() && ctx.inbox.length >= 4);
|
||||
assert.deepEqual(ctx.inbox.map((m) => m.body), ["refused: capacity-full", '{"run":"new-run"}', "refused: unknown-tool", "refused: unknown-role"]);
|
||||
assert.deepEqual(ctx.inbox.map((m) => m.body.split("\n")[0]), ["refused: capacity-full", '{"run":"new-run"}', "refused: unknown-tool", "refused: unknown-role"]);
|
||||
assert.deepEqual(ctx.launches, [{ cap: ctx.cap, instance: "coder" }, { cap: ctx.cap, instance: "reviewer" }]);
|
||||
});
|
||||
|
||||
test("my_rails reaches the live broker with the runner's own capability and refuses an identity selector", async t => {
|
||||
const ctx = await setup(t, { tools: typedTools({ resolved: resolvedFor(t, "coder") }) });
|
||||
ctx.inbox = [];
|
||||
const r = startRunner(ctx.runDir, ctx.cap);
|
||||
t.after(() => r.child.kill("SIGKILL"));
|
||||
ctx.call(ctx.pm, "message.send", { to: "coder", body: 'TOOL my_rails {}' });
|
||||
await until(() => replies(ctx)() && ctx.inbox.length >= 1);
|
||||
assert.deepEqual(JSON.parse(ctx.inbox[0].body), ctx.call(ctx.cap, "my_rails", {}));
|
||||
ctx.call(ctx.pm, "message.send", { to: "coder", body: 'TOOL my_rails {"role":"cto"}' });
|
||||
await until(() => replies(ctx)() && ctx.inbox.length >= 2);
|
||||
assert.match(ctx.inbox[1].body, /^refused: invalid-request\nRail: .+\nNext step: .+/);
|
||||
});
|
||||
|
||||
// Row 132 (#1628, REQ-ROLE-2, REQ-COS-1, REQ-LIMIT-1): the chief's two tools reach the broker by their verbs
|
||||
// with the arguments the session gave, and a refusal comes back with the broker's code.
|
||||
// Row 134 (#1669): organization_revision reaches the broker's organization.revision and answers the revision alone.
|
||||
@@ -509,7 +522,7 @@ test("end_run, organization_revision and change_organization reach the broker wi
|
||||
send("change_organization", { ...change, surprise: 1 });
|
||||
send("change_organization", { ...change, revision: "e".repeat(64) });
|
||||
await until(() => replies(ctx)() && ctx.inbox.length >= 8);
|
||||
assert.deepEqual(ctx.inbox.map((m) => m.body), [
|
||||
assert.deepEqual(ctx.inbox.map((m) => m.body.split("\n")[0]), [
|
||||
'{"revoked":true}',
|
||||
"refused: role-end-refused",
|
||||
"refused: not-held",
|
||||
@@ -541,7 +554,7 @@ test("set_name reaches the broker's name.set with the tool's arguments, and refu
|
||||
send({ instance: "coder", name: "rex" });
|
||||
send({ instance: "coder", name: "pm" });
|
||||
await until(() => replies(ctx)() && ctx.inbox.length >= 2);
|
||||
assert.deepEqual(ctx.inbox.map((m) => m.body), [
|
||||
assert.deepEqual(ctx.inbox.map((m) => m.body.split("\n")[0]), [
|
||||
'{"instance":"coder","name":"rex"}',
|
||||
"refused: name-is-instance",
|
||||
]);
|
||||
|
||||
@@ -18,7 +18,7 @@ test("the PM gets its task verbs and the reads, and no launch", (t) => {
|
||||
"intake_status", "intake_projects", "intake_open", "intake_question", "intake_propose", "deliver_request", "advance_request", "submit_prd",
|
||||
"task_create", "task_assign", "task_reassign", "task_schedule", "task_priority_change", "task_scope_change",
|
||||
"task_update_assigned", "task_close",
|
||||
"read_spec", "write_record", "raise_decision", "resolve_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "list_requirements",
|
||||
"read_spec", "write_record", "raise_decision", "resolve_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "list_requirements", "my_rails",
|
||||
]);
|
||||
// Row 119 (#1613): only the chief of staff launches (REQ-COS-1).
|
||||
assert.equal(pm.launch, null);
|
||||
@@ -47,7 +47,7 @@ test("send_message offers the BLOCK class, beside the classes it already had", (
|
||||
test("the chief of staff gets launch, send_message, end_run, hire, fire, change_organization and set_name, and no task verbs", (t) => {
|
||||
const chief = resolvedFor(t, "chief");
|
||||
const tools = typedTools({ resolved: chief, tracker: true });
|
||||
assert.deepEqual(names(tools), ["send_message", "launch", "end_run", "hire", "fire", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
|
||||
assert.deepEqual(names(tools), ["send_message", "launch", "end_run", "hire", "fire", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "my_rails"]);
|
||||
const launch = tools.find((x) => x.name === "launch");
|
||||
assert.equal(launch.verb, "launch");
|
||||
assert.equal(launch.action, "role.launch");
|
||||
@@ -225,7 +225,7 @@ test("no role's tools send instances or instances.set, and change_organization t
|
||||
test("the assistant gets send_message, the reads and the web tools (it holds the web grant), no launch", (t) => {
|
||||
const assistant = resolvedFor(t, "assistant", (d) => (d.roles.assistant = { definition: "assistant", holder: "mos" }));
|
||||
const tools = typedTools({ resolved: assistant, tracker: true });
|
||||
assert.deepEqual(names(tools), ["send_message", "web_search", "web_fetch", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
|
||||
assert.deepEqual(names(tools), ["send_message", "web_search", "web_fetch", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "my_rails"]);
|
||||
assert.equal(assistant.launch, null);
|
||||
assert.deepEqual(untypedActions(assistant, tools), []);
|
||||
});
|
||||
@@ -260,7 +260,7 @@ test("list_requirements reads the requirement ids, for a task.create holder with
|
||||
test("a coder gets no launch, no resolve_decision, and no task tools without a tracker", (t) => {
|
||||
const coder = resolvedFor(t, "coder");
|
||||
const tools = typedTools({ resolved: coder });
|
||||
assert.deepEqual(names(tools), ["send_message", "request_review", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail"]);
|
||||
assert.deepEqual(names(tools), ["send_message", "request_review", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "my_rails"]);
|
||||
// Held actions without a tool are listed, so the manifest shows them. With
|
||||
// no clone to push from, push_working is one of them.
|
||||
assert.deepEqual(untypedActions(coder, tools), ["task.update.assigned", "git.push.working", "task.scope.change"]);
|
||||
@@ -270,7 +270,7 @@ test("a coder with a tracker gets its own task tools and no task_reassign", (t)
|
||||
const coder = resolvedFor(t, "coder");
|
||||
assert.deepEqual(names(typedTools({ resolved: coder, tracker: true })), [
|
||||
"send_message", "task_scope_change", "task_update_assigned", "request_review",
|
||||
"read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task",
|
||||
"read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "my_rails",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -283,7 +283,7 @@ test("launch only when the business's launch block names the instance as launche
|
||||
|
||||
test("an action outside the instance's authority has no tool", (t) => {
|
||||
const coder = resolvedFor(t, "coder", (d) => (d.roles.coder.vars["limits.authority"] = ["review.request"]));
|
||||
assert.deepEqual(names(typedTools({ resolved: coder, tracker: true, projects: ["stack"] })), ["request_review", "read_spec", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
|
||||
assert.deepEqual(names(typedTools({ resolved: coder, tracker: true, projects: ["stack"] })), ["request_review", "read_spec", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "my_rails"]);
|
||||
});
|
||||
|
||||
// Row 93 (#1587): the review loop's tools, by authority, and the git ones only with a clone.
|
||||
@@ -292,7 +292,7 @@ test("the review loop: push_working and request_review for the coder, fetch_revi
|
||||
const tools = typedTools({ resolved: coder, tracker: true, projects: ["stack", "site"] });
|
||||
assert.deepEqual(names(tools), [
|
||||
"send_message", "task_scope_change", "task_update_assigned", "push_working", "request_review",
|
||||
"read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task",
|
||||
"read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "my_rails",
|
||||
]);
|
||||
assert.deepEqual(untypedActions(coder, tools), []);
|
||||
const push = tools.find((x) => x.name === "push_working");
|
||||
@@ -309,7 +309,7 @@ test("the review loop: push_working and request_review for the coder, fetch_revi
|
||||
|
||||
const reviewer = resolvedFor(t, "reviewer");
|
||||
const rtools = typedTools({ resolved: reviewer, tracker: true, projects: ["stack"] });
|
||||
assert.deepEqual(names(rtools), ["send_message", "task_update_assigned", "fetch_review", "review_verdict", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
|
||||
assert.deepEqual(names(rtools), ["send_message", "task_update_assigned", "fetch_review", "review_verdict", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "my_rails"]);
|
||||
const fetch = rtools.find((x) => x.name === "fetch_review");
|
||||
assert.deepEqual([fetch.verb, fetch.action], ["working.fetch", null]);
|
||||
assert.deepEqual(fetch.parameters.required, ["task_ref", "request"]);
|
||||
|
||||
@@ -62,14 +62,20 @@ for (const [name, value, detail] of [
|
||||
});
|
||||
}
|
||||
|
||||
test('agent sees validation detail while other refusal codes remain code-only', async t => {
|
||||
test('agent sees validation detail and static guidance without unrelated refusal reasons', async t => {
|
||||
const f = await setup(t);
|
||||
await assert.rejects(f.write('requirements/REQ-1.json', { ...requirement, title: '' }),
|
||||
/refused: project-record-invalid: requirement\.title: too short/);
|
||||
await assert.rejects(f.write('spec/secret-marker.md', '# text'), { message: 'refused: project-spec-human-only' });
|
||||
const refusal = message => error => {
|
||||
assert.equal(error.message.split('\n')[0], message);
|
||||
assert.match(error.message, /\nRail: .+\nNext step: .+/);
|
||||
assert.doesNotMatch(error.message, /secret-marker/);
|
||||
return true;
|
||||
};
|
||||
await assert.rejects(f.write('spec/secret-marker.md', '# text'), refusal('refused: project-spec-human-only'));
|
||||
const path = join(f.root, 'other-tools.sock');
|
||||
const server = await toolServer({ path, tools: [{ name: 'write_record', verb: 'record.write' }], local: {},
|
||||
client: { call() { throw Object.assign(new Error('hidden'), { code: 'project-busy', reason: 'secret-marker' }); } } });
|
||||
t.after(() => server.close());
|
||||
await assert.rejects(callTool(path, 'write_record', {}), { message: 'refused: project-busy' });
|
||||
await assert.rejects(callTool(path, 'write_record', {}), refusal('refused: project-busy'));
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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)) };
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Caller-only descriptions, shared by launch and live bus reads. Inputs are validated policy,
|
||||
// never credential references, vars, call arguments or file contents.
|
||||
import { GATED_ONLY } from './vocabulary.mjs';
|
||||
|
||||
const DOMAINS = Object.freeze({ 'git.merge.protected': 'technical', 'policy.change': 'delivery' });
|
||||
|
||||
// TI extension point for AU: today's fixed levels, without changing any routing.
|
||||
export function decisionAutonomy(decisionClass) {
|
||||
return ['cross-role', 'merge', 'policy'].includes(decisionClass) ? 'out of the loop' : 'in the loop';
|
||||
}
|
||||
|
||||
export function ownRails({ instance, authority, network, credentials = [], messaging = null, arbiters = {}, launcher = null }) {
|
||||
const actions = [];
|
||||
for (const [kind, held] of Object.entries(authority ?? {})) {
|
||||
for (const action of held) {
|
||||
const chiefOnly = ['role.hire', 'role.fire'].includes(action) && launcher !== instance;
|
||||
const hard = GATED_ONLY.includes(action) || (action === 'role.launch' && launcher !== instance);
|
||||
const domains = DOMAINS[action] ? [DOMAINS[action]] : ['delivery', 'technical'];
|
||||
const routes = chiefOnly ? [{ to: 'unavailable' }] : hard ? [{ to: 'human' }] : kind === 'withinRole' ? [{ to: 'alone' }]
|
||||
: domains.map(domain => ({ domain, to: arbiters[domain] === instance ? 'human' : arbiters[domain] ?? 'unconfigured' }));
|
||||
actions.push({ action, routes });
|
||||
}
|
||||
}
|
||||
const resolve = [...(authority?.withinRole ?? []), ...(authority?.crossRole ?? [])]
|
||||
.filter(action => action.startsWith('decision.resolve.')).map(right => {
|
||||
const domain = right.slice('decision.resolve.'.length);
|
||||
const classes = domain === 'technical' ? ['cross-role', 'merge'] : domain === 'delivery' ? ['cross-role', 'policy'] : [];
|
||||
return { right, classes: classes.map(name => ({ name, level: decisionAutonomy(name) })) };
|
||||
});
|
||||
return {
|
||||
actions, resolve, network: network ?? 'unknown',
|
||||
credentials: credentials.map(({ service, scopes }) => ({ service, ...(scopes === undefined ? {} : { scopes: structuredClone(scopes) }) })),
|
||||
messaging: messaging ? { sendTo: [...messaging.sendTo], receiveFrom: [...messaging.receiveFrom] } : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function railsText(rails) {
|
||||
const lines = ['## Your rails', '', 'These are your resolved rails. Call my_rails for the current rails after a narrowing.'];
|
||||
for (const { action, routes } of rails.actions) {
|
||||
const route = routes.map(({ domain, to }) => `${domain ? `${domain}${domain === 'delivery' && routes.length > 1 ? ' (default)' : ''}: ` : ''}${to === 'human' ? 'a person' : to === 'alone' ? 'alone' : to === 'unavailable' ? 'unavailable here; only the configured chief can hire or fire' : to === 'unconfigured' ? 'unconfigured; report missing arbiter' : `arbiter instance ${JSON.stringify(to)}`}`).join('; ');
|
||||
lines.push(`- ${action}: ${route}.`);
|
||||
}
|
||||
if (!rails.actions.length) lines.push('- Held actions: none.');
|
||||
for (const { right, classes } of rails.resolve) lines.push(`- ${right} levels: ${classes.map(c => `${c.name}: ${c.level}`).join('; ') || 'unconfigured'}.`);
|
||||
lines.push('- A resolve right must be held withinRole, and the decision must route to you. PRD approval also follows the request mode and author checks.');
|
||||
lines.push('- Unheld actions are gated. Hard waits always go to a person; raise with blocking false when there is no task. A current spec citation can route other gated work to its arbiter.');
|
||||
lines.push('- Revoking a protected instance is a hard wait; ending it follows the chief/person protection checks. Messaging also checks the recipient\'s policy.');
|
||||
lines.push(`- Network: ${rails.network}.`);
|
||||
for (const credential of rails.credentials) lines.push(`- Credential service ${credential.service}; scopes: ${credential.scopes === undefined ? 'none declared' : JSON.stringify(credential.scopes)}.`);
|
||||
if (!rails.credentials.length) lines.push('- Credential services: none.');
|
||||
lines.push(`- Messaging sendTo: ${rails.messaging ? rails.messaging.sendTo.join(', ') || 'none' : 'unrestricted by your definition'}.`);
|
||||
lines.push(`- Messaging receiveFrom: ${rails.messaging ? rails.messaging.receiveFrom.join(', ') || 'none' : 'unrestricted by your definition'}.`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
// the record a launcher or the broker uses. Plain values: the most specific
|
||||
// layer wins. Limits: every layer narrows, nothing widens.
|
||||
|
||||
import { ownRails } from "./rails.mjs";
|
||||
import { refuse } from "./errors.mjs";
|
||||
import { ACTIONS, NETWORKS, isHostKind } from "./vocabulary.mjs";
|
||||
import { checkVars, mergeVars } from "./vars.mjs";
|
||||
@@ -89,6 +90,7 @@ export function resolveInstance({ system, business, project = null, instance })
|
||||
limits: { tools, network, authority: { withinRole, crossRole } },
|
||||
// Row 210 (#1707, design D5): a host key is used only by the bus child, never by the session,
|
||||
// so it stays out of the session's credentials and the harness's needs.
|
||||
rails: ownRails({ instance, authority: { withinRole, crossRole }, network, credentials: definition.credentials, messaging: definition.messaging, arbiters: business.arbiters, launcher: business.launch?.by }),
|
||||
credentials: Object.fromEntries(Object.entries(entry.credentials).filter(([kind]) => !isHostKind(kind))),
|
||||
tracker: entry.tracker,
|
||||
launch,
|
||||
|
||||
@@ -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" } });
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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/);
|
||||
}
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -351,7 +351,7 @@ test("a confined session's shell reaches its own workspace and run directory and
|
||||
assert.equal(readFileSync(join(x.ws, "probe"), "utf8"), "ws\n");
|
||||
// The harness reaches its tool socket: the refusal is the tool server's,
|
||||
// not a failed connect.
|
||||
assert.equal(await x.ask("TOOL no_such_tool {}"), "refused: unknown-tool");
|
||||
assert.equal((await x.ask("TOOL no_such_tool {}")).split("\n")[0], "refused: unknown-tool");
|
||||
});
|
||||
|
||||
test("a bind that exposes a stand-in, or a missing workspace bind, fails the matching check", { skip, timeout: 60000 }, async (t) => {
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
// #1873 Phase A slice A2: the owner's Settings > Agents writes for the root and project agent levels
|
||||
// (design docs/plans/2026-11-10_agent-levels-design.md sections 6, 7 and 9 item 1).
|
||||
//
|
||||
// GET /api/agents/root root entries, the file's revision, writable
|
||||
// POST /api/agents/root {revision, name, entry}: entry is a patch
|
||||
// {definition?, vars?, excludeOrgs?} (a var
|
||||
// set to null is removed) or null to remove
|
||||
// GET /api/orgs/<org>/agent-levels/projects/<project> the project's entries and the org revision
|
||||
// POST /api/orgs/<org>/agent-levels/projects/<project> {revision, name, entry}: entry is a define
|
||||
// {definition, vars?}, a narrow {vars}, an
|
||||
// exclude {exclude: true} or null to remove
|
||||
//
|
||||
// These are registered hooks, so serve.mjs's hook fence runs first, as it does for
|
||||
// /api/agents/levels: a signed-in session on the page's name, no granted user on a write, and on a
|
||||
// POST the exact Origin and Sec-Fetch-Site: same-origin. An org path is resolved for the session
|
||||
// before the hook runs. Here: the owner's own session only (owner-only otherwise), and a POST only
|
||||
// when the Console was started with writes on (read-only otherwise). The writers then check again:
|
||||
// the root writer takes the session's principal as the owner, and the project writer acts as the
|
||||
// organization's human, as the bus's human transport does.
|
||||
//
|
||||
// A response or trail entry names agents, keys and revisions, never a var value or a host path.
|
||||
import { organizationWriter } from '../../organization/src/organization.mjs';
|
||||
import { changeRootAgent, patchRootEntry, projectAgentSummary, readRootAgentsFile, rootAgentSummary } from '../../organization/src/agent-levels-write.mjs';
|
||||
import { ID_PATTERN } from '../../organization/src/vocabulary.mjs';
|
||||
import { actor } from './action-trail.mjs';
|
||||
|
||||
export const ROOT_ROUTE = '/api/agents/root';
|
||||
export const PROJECT_ROUTE = /^\/api\/orgs\/([^/]+)\/agent-levels\/projects\/([^/]+)$/;
|
||||
// routeTable takes GET and POST only; any other method never reaches these hooks (serve.mjs answers it).
|
||||
const METHODS = ['GET', 'POST'];
|
||||
const REVISION = /^[a-f0-9]{64}$/;
|
||||
// Agent and project names are the writers' ids (requireId): checked here so a bad one is a 400
|
||||
// naming the field, not a writer error the Console can't classify.
|
||||
const NAME = ID_PATTERN;
|
||||
const NAME_RULE = 'lowercase letters, digits, dot, dash or underscore, at most 64';
|
||||
const LIMIT = 4096;
|
||||
const ROOT_PATCH = ['definition', 'vars', 'excludeOrgs'];
|
||||
const PROJECT_ENTRY = ['definition', 'vars', 'exclude'];
|
||||
const STATUS = {
|
||||
'invalid-request': 400, 'agents-invalid': 400, 'project-agents-invalid': 400, 'project-agents-library': 400, 'no-change': 400,
|
||||
'human-required': 403, 'owner-only': 403, 'read-only': 403,
|
||||
'project-not-found': 404, 'agent-not-found': 404,
|
||||
'revision-conflict': 409, 'agents-locked': 409, 'organization-busy': 409,
|
||||
'agents-unsafe': 503, 'organization-uncertain': 503,
|
||||
};
|
||||
// The writers' own text for these codes names keys and ids only (A1's checks and the A2 scrubber).
|
||||
const SAYS = new Set(['agents-invalid', 'project-agents-invalid', 'project-agents-library', 'no-change', 'human-required',
|
||||
'project-not-found', 'agent-not-found', 'revision-conflict', 'agents-locked', 'organization-busy', 'agents-unsafe']);
|
||||
const FIXED = {
|
||||
'outcome-unknown': 'the change may have been made; nothing was retried. Read the agents again before trying',
|
||||
committed: 'the change was written; its receipt may be missing. Read the agents again before trying',
|
||||
};
|
||||
|
||||
const one = (req, key) => req.headersDistinct[key]?.length === 1 ? req.headersDistinct[key][0] : null;
|
||||
function json(res, status, value) {
|
||||
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
||||
res.end(JSON.stringify(value));
|
||||
}
|
||||
const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
// Every string a var value carries, and each non-string value as JSON: a refusal that quotes any of
|
||||
// them gets a fixed line instead.
|
||||
function values(entry) {
|
||||
const out = [];
|
||||
const walk = v => {
|
||||
if (typeof v === 'string') { if (v.length >= 3) out.push(v); }
|
||||
else if (Array.isArray(v)) v.forEach(walk);
|
||||
else if (plain(v)) Object.entries(v).forEach(([k, x]) => { if (k.length >= 3) out.push(k); walk(x); });
|
||||
else if (v !== null && v !== undefined) out.push(JSON.stringify(v));
|
||||
};
|
||||
if (plain(entry?.vars)) Object.values(entry.vars).forEach(walk);
|
||||
return out.filter(s => s.length >= 3);
|
||||
}
|
||||
|
||||
// A coded refusal's own text when it names no value and no path; a fixed line otherwise.
|
||||
function refusal(error, entry) {
|
||||
const code = typeof error?.code === 'string' && Object.hasOwn(STATUS, error.code) ? error.code : 'outcome-unknown';
|
||||
if (error?.committed) return { status: 503, body: { error: 'receipt-missing', committed: true, message: FIXED.committed } };
|
||||
const text = typeof error?.message === 'string' ? error.message : '';
|
||||
const quoted = values(entry).some(v => text.includes(v));
|
||||
const message = SAYS.has(code) && text && text.length <= 400 && !text.includes('/') && !quoted ? text : (FIXED[code] ?? 'refused');
|
||||
return { status: STATUS[code] ?? 503, body: { error: code, message } };
|
||||
}
|
||||
|
||||
async function body(req) {
|
||||
if (one(req, 'content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') return null;
|
||||
const chunks = []; let size = 0;
|
||||
for await (const chunk of req) { size += chunk.length; if (size > LIMIT) return 'too-large'; chunks.push(chunk); }
|
||||
let value;
|
||||
try { value = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { return null; }
|
||||
const keys = ['revision', 'name', 'entry'];
|
||||
if (!plain(value) || Object.keys(value).length !== keys.length || !keys.every(k => Object.hasOwn(value, k))) return null;
|
||||
if (typeof value.revision !== 'string' || !REVISION.test(value.revision) || typeof value.name !== 'string') return null;
|
||||
if (!NAME.test(value.name)) return 'bad-name';
|
||||
return value;
|
||||
}
|
||||
const only = (entry, keys) => plain(entry) && Object.keys(entry).length > 0 && Object.keys(entry).every(k => keys.includes(k));
|
||||
|
||||
// `configDir` and `rolesDir` are the instance's; `trail` is the Console action trail.
|
||||
export function agentLevelsWriteRoutes({ configDir, rolesDir, trail = null, log = () => {} }) {
|
||||
const writer = organizationWriter({ dir: configDir, rolesDir });
|
||||
const note = (session, entry) => {
|
||||
if (!trail) return;
|
||||
try { trail({ by: actor(session), ...entry }); }
|
||||
catch { log(`${new Date().toISOString()} trail write failed for ${entry.action}`); }
|
||||
};
|
||||
// The fence every request takes after serve.mjs's: method, owner, writes on, then the body.
|
||||
async function gate(req, res, { session, owner, send }) {
|
||||
if (!['GET', 'POST'].includes(req.method)) { json(res, 405, { error: 'method-not-allowed' }); return null; }
|
||||
if (!owner || !session) { json(res, 403, { error: 'owner-only', message: 'agent levels are the owner\'s' }); return null; }
|
||||
if (req.method === 'GET') return {};
|
||||
if (!send) { json(res, 403, { error: 'read-only', message: 'this Console was started without writes' }); return null; }
|
||||
const value = await body(req);
|
||||
if (value === 'too-large') { json(res, 413, { error: 'invalid-request', message: `a change is at most ${LIMIT} bytes` }); return null; }
|
||||
if (value === 'bad-name') { json(res, 400, { error: 'invalid-request', message: `name must be an id: ${NAME_RULE}` }); return null; }
|
||||
if (!value) { json(res, 400, { error: 'invalid-request', message: 'this takes revision, name and entry, nothing else' }); return null; }
|
||||
return value;
|
||||
}
|
||||
|
||||
async function root(req, res, context) {
|
||||
const value = await gate(req, res, context);
|
||||
if (!value) return;
|
||||
const { session } = context;
|
||||
if (req.method === 'GET') {
|
||||
try {
|
||||
const read = await readRootAgentsFile(configDir);
|
||||
return json(res, 200, { revision: read.revision, state: read.exists ? 'present' : 'absent', agents: rootAgentSummary(read.document), writable: Boolean(context.send) });
|
||||
} catch (error) { const r = refusal(error, null); return json(res, r.status, r.body); }
|
||||
}
|
||||
const { revision, name, entry } = value;
|
||||
if (entry !== null && !only(entry, ROOT_PATCH)) return json(res, 400, { error: 'invalid-request', message: 'entry is null or takes definition, vars and excludeOrgs' });
|
||||
if (entry?.vars !== undefined && !plain(entry.vars)) return json(res, 400, { error: 'invalid-request', message: 'vars is an object of keys' });
|
||||
const action = entry === null ? 'agents.root.remove' : 'agents.root.set';
|
||||
const principal = { provider: session.provider, iss: session.iss, sub: session.sub };
|
||||
let receipt;
|
||||
try {
|
||||
receipt = await changeRootAgent(configDir, { revision, name, entry: entry === null ? null : old => patchRootEntry(old, entry) }, { actor: principal, owner: principal, rolesDir });
|
||||
} catch (error) {
|
||||
const r = refusal(error, entry);
|
||||
note(session, { action, agent: name, outcome: r.body.error });
|
||||
return json(res, r.status, r.body);
|
||||
}
|
||||
note(session, { action, agent: name, changed: receipt.changed, outcome: 'done' });
|
||||
return json(res, 200, { outcome: 'done', receipt });
|
||||
}
|
||||
|
||||
async function project(req, res, context) {
|
||||
const value = await gate(req, res, context);
|
||||
if (!value) return;
|
||||
const { session, orgId } = context;
|
||||
let projectId;
|
||||
try { projectId = decodeURIComponent(PROJECT_ROUTE.exec(context.path)?.[2] ?? ''); } catch { projectId = ''; }
|
||||
if (typeof orgId !== 'string' || !NAME.test(projectId)) return json(res, 404, { error: 'project-not-found', message: 'no such project' });
|
||||
if (req.method === 'GET') {
|
||||
try {
|
||||
const { document, revision } = writer.read(orgId);
|
||||
const target = document.projects?.[projectId];
|
||||
if (!plain(target)) return json(res, 404, { error: 'project-not-found', message: `projects.${projectId} does not exist` });
|
||||
return json(res, 200, { organization: orgId, project: projectId, revision, library: target.kind === 'library', agents: projectAgentSummary(target.agents ?? {}), writable: Boolean(context.send) });
|
||||
} catch (error) { const r = refusal(error, null); return json(res, r.status, r.body); }
|
||||
}
|
||||
const { revision, name, entry } = value;
|
||||
if (entry !== null && !only(entry, PROJECT_ENTRY)) return json(res, 400, { error: 'invalid-request', message: 'entry is null or takes definition, vars or exclude' });
|
||||
const form = entry === null ? 'remove' : Object.hasOwn(entry, 'exclude') ? 'exclude' : Object.hasOwn(entry, 'definition') ? 'define' : 'narrow';
|
||||
const base = { org: orgId, action: 'project.agents.set', project: projectId, agent: name, form };
|
||||
let result;
|
||||
// The Console's owner is each organization's human, as the bus binds it (bindConsole with the
|
||||
// document's human; request-bootstrap's addProject passes document.human the same way). The
|
||||
// owner gate above is the identity check; the writer re-reads under its lock and refuses if the
|
||||
// human changed since this read.
|
||||
try { result = writer.setProjectAgent(orgId, { revision, project: projectId, name, entry }, writer.read(orgId).document.human); }
|
||||
catch (error) {
|
||||
const r = refusal(error, entry);
|
||||
note(session, { ...base, outcome: r.body.error });
|
||||
return json(res, r.status, r.body);
|
||||
}
|
||||
note(session, { ...base, outcome: 'done' });
|
||||
return json(res, 200, { outcome: 'done', receipt: { verb: 'project.agents.set', organization: orgId, project: projectId, agent: name, form, revisionBefore: revision, revisionAfter: result.revision, transaction: result.transaction } });
|
||||
}
|
||||
|
||||
return table => {
|
||||
for (const method of METHODS) {
|
||||
table.add(method, ROOT_ROUTE, root);
|
||||
table.add(method, PROJECT_ROUTE, project);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import { readFileSync, statSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { isAbsolute, join } from 'node:path';
|
||||
import { actionTrail } from './action-trail.mjs';
|
||||
import { agentLevelsWriteRoutes } from './agent-levels-write.mjs';
|
||||
import { RESTART_EXIT } from '../../cli/src/org-console.mjs';
|
||||
import { ID_PATTERN } from '../../organization/src/vocabulary.mjs';
|
||||
import { selectorScope } from '../../organization/src/selector.mjs';
|
||||
@@ -189,8 +190,10 @@ try {
|
||||
server.closeAllConnections();
|
||||
} : null;
|
||||
const trail = system ? actionTrail(join(system.dataRoot, 'console')) : null;
|
||||
// #1873 A2: Settings > Agents writes for the root and project levels, beside the levels read.
|
||||
const agentLevelRoutes = organizations ? [agentLevelsWriteRoutes({ configDir: configDir(), rolesDir: rolesDir(), trail })] : [];
|
||||
try {
|
||||
server = await startServer({ restart, trail, ...(lan ? { host: lan.bind, devAllowHosts: lan.allowHosts } : {}), board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, libraryRevert, librarySave, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, userOptions, federation: system?.federation ?? null, organizations, agentLevels, preselect: options.business ?? null });
|
||||
server = await startServer({ restart, trail, ...(lan ? { host: lan.bind, devAllowHosts: lan.allowHosts } : {}), board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, libraryRevert, librarySave, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, userOptions, federation: system?.federation ?? null, organizations, agentLevels, routes: agentLevelRoutes, preselect: options.business ?? null });
|
||||
} catch (err) { sso?.close(); throw err; }
|
||||
if (testerSocket) {
|
||||
const call = busCaller({ dataRoot: system.dataRoot, socket: socketPath(system.dataRoot), business: options.business ?? null, cli: TESTER_CLI });
|
||||
|
||||
@@ -230,3 +230,216 @@
|
||||
|
||||
window.MosaicAgents = Object.freeze({ section, restartMarkup, isThread: definition => threadDefinitions.has(definition) });
|
||||
})();
|
||||
|
||||
/* #1873 A2: Settings > Agents, the root and project agent levels. bus.js awaits section() for the
|
||||
Agents page and puts it under the organization's own agents. Each grid comes from a fresh read:
|
||||
GET /api/agents/root for the root file and GET /api/orgs/<org>/agent-levels/projects/<project>
|
||||
for a project, each with the revision a write sends back. After a write the section reads
|
||||
everything again and repaints. The server checks the owner, the exact Origin and Fetch Metadata,
|
||||
the revision and the documents; this file only asks and reports. It never shows a var value
|
||||
other than the model, and its own messages name keys only. */
|
||||
(() => {
|
||||
'use strict';
|
||||
const $ = id => document.getElementById(id);
|
||||
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
||||
const clock = () => new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
const enc = encodeURIComponent;
|
||||
const UNKNOWN = 'Outcome unknown. Nothing was retried. The grid below was read again.';
|
||||
let root = null, levels = null, project = null, selected = { org: '', project: '' };
|
||||
let receipts = { root: '', project: '' }, busy = false, confirming = null;
|
||||
// What each form holds, kept here so a polling render, a picker change or the other form's save
|
||||
// repaints it as typed. A draft keeps the revision it began at, so a save sends that and a file
|
||||
// changed meanwhile refuses rather than being overwritten unseen. The project draft belongs to its
|
||||
// organization and project. Only the submitted form's own success clears it.
|
||||
const drafts = { root: null, project: null };
|
||||
|
||||
const read = url => fetch(url, { cache: 'no-store', redirect: 'manual' }).then(async r => {
|
||||
if (r.type === 'opaqueredirect') return { signedOut: true };
|
||||
let body = null; try { body = await r.json(); } catch { /* unreadable */ }
|
||||
return r.ok ? body : { refused: body?.error ?? 'unreadable', status: r.status };
|
||||
}).catch(() => null);
|
||||
|
||||
async function load() {
|
||||
[levels, root] = await Promise.all([read('/api/agents/levels'), read('/api/agents/root')]);
|
||||
const orgs = orgsOf();
|
||||
if (!orgs.some(o => o.id === selected.org)) selected = { org: window.MosaicOrg?.id && orgs.some(o => o.id === window.MosaicOrg.id) ? window.MosaicOrg.id : (orgs[0]?.id ?? ''), project: '' };
|
||||
const projects = orgs.find(o => o.id === selected.org)?.projects ?? [];
|
||||
if (!projects.includes(selected.project)) selected.project = projects[0] ?? '';
|
||||
project = selected.org && selected.project ? await read(`/api/orgs/${enc(selected.org)}/agent-levels/projects/${enc(selected.project)}`) : null;
|
||||
}
|
||||
const orgsOf = () => (Array.isArray(levels?.orgs) ? levels.orgs : []).filter(o => o?.state === 'ok')
|
||||
.map(o => ({ id: o.id, projects: [...new Set([...Object.keys(o.projects ?? {}), ...Object.keys(o.defined ?? {})])].sort() }));
|
||||
const writable = () => root?.writable === true;
|
||||
|
||||
// KEY=VALUE lines. A value that reads as JSON other than a string (a number, true, false, a list
|
||||
// or an object) is sent as JSON. A malformed line is named by its number, never by its text.
|
||||
function parseVars(text) {
|
||||
const vars = {};
|
||||
const lines = String(text ?? '').split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
const at = line.indexOf('=');
|
||||
if (at < 1) throw new Error(`Variables line ${i + 1} needs KEY=VALUE.`);
|
||||
const key = line.slice(0, at).trim(), raw = line.slice(at + 1).trim();
|
||||
if (Object.hasOwn(vars, key)) throw new Error(`Variable ${key} is given twice.`);
|
||||
let value = raw;
|
||||
try { const parsed = JSON.parse(raw); if (typeof parsed !== 'string') value = parsed; } catch { /* a plain string */ }
|
||||
vars[key] = value;
|
||||
}
|
||||
return vars;
|
||||
}
|
||||
const list = text => String(text ?? '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
|
||||
function rootGrid() {
|
||||
if (!root || root.refused || root.signedOut) return `<p class="small warn" id="agent-levels-root-grid">The root agent file could not be read${root?.refused ? ` (${esc(root.refused)})` : ''}. Nothing can be changed until it reads.</p>`;
|
||||
if (!root.agents.length) return `<p class="muted" id="agent-levels-root-grid">No root agents${root.state === 'absent' ? '; the root agent file does not exist yet' : ''}.</p>`;
|
||||
const off = !writable() || busy ? ' disabled' : '';
|
||||
return `<div class="table-wrap" role="region" aria-label="Root agents"><table id="agent-levels-root-grid"><thead><tr><th scope="col">Agent</th><th scope="col">Definition</th><th scope="col">Model</th><th scope="col">Other variables</th><th scope="col">Not in</th><th scope="col">Actions</th></tr></thead><tbody>${root.agents.map(a => `<tr data-root-agent="${esc(a.name)}"><th scope="row">${esc(a.name)}</th><td>${esc(a.definition ?? '')}</td><td>${a.model ? `<code>${esc(a.model)}</code>` : '<span class="muted">Inherited</span>'}</td><td>${a.varKeys.length ? a.varKeys.map(k => `<code>${esc(k)}</code>`).join(' ') : '<span class="muted">None</span>'}</td><td>${a.excludeOrgs.length ? a.excludeOrgs.map(esc).join(', ') : '<span class="muted">Every organization</span>'}</td><td>${writable() ? `<button type="button" data-level-remove="root" data-level-name="${esc(a.name)}"${off}>${confirming === `root:${a.name}` ? 'Confirm remove' : 'Remove'}</button>` : ''}</td></tr>`).join('')}</tbody></table></div>`;
|
||||
}
|
||||
|
||||
function projectGrid() {
|
||||
if (!selected.project) return '<p class="muted" id="agent-levels-project-grid">This organization has no projects.</p>';
|
||||
if (!project || project.refused || project.signedOut) return `<p class="small warn" id="agent-levels-project-grid">The project could not be read${project?.refused ? ` (${esc(project.refused)})` : ''}.</p>`;
|
||||
if (project.library) return '<p class="muted" id="agent-levels-project-grid">This is a library project; it takes no agents.</p>';
|
||||
if (!project.agents.length) return '<p class="muted" id="agent-levels-project-grid">No project entries. The project has the organization\'s agents and the root agents.</p>';
|
||||
const off = !writable() || busy ? ' disabled' : '';
|
||||
const what = a => a.form === 'define' ? `Defines ${esc(a.definition ?? '')}` : a.form === 'narrow' ? 'Narrows an inherited agent' : 'Excludes an inherited agent';
|
||||
return `<div class="table-wrap" role="region" aria-label="Project agent entries"><table id="agent-levels-project-grid"><thead><tr><th scope="col">Agent</th><th scope="col">Entry</th><th scope="col">Variables</th><th scope="col">Actions</th></tr></thead><tbody>${project.agents.map(a => `<tr data-project-agent="${esc(a.name)}" data-form="${esc(a.form)}"><th scope="row">${esc(a.name)}</th><td>${what(a)}</td><td>${a.varKeys?.length ? a.varKeys.map(k => `<code>${esc(k)}</code>`).join(' ') : '<span class="muted">None</span>'}</td><td>${writable() ? `<button type="button" data-level-remove="project" data-level-name="${esc(a.name)}"${off}>${confirming === `project:${a.name}` ? 'Confirm remove' : 'Remove'}</button>` : ''}</td></tr>`).join('')}</tbody></table></div>`;
|
||||
}
|
||||
|
||||
const receiptLine = which => {
|
||||
const text = receipts[which];
|
||||
return `<p role="status" id="agent-levels-${which}-receipt" class="${text.startsWith('Saved') || text.startsWith('Removed') ? 'settings-success' : text ? 'banner err' : ''}">${esc(text)}</p>`;
|
||||
};
|
||||
|
||||
const projectDraft = () => drafts.project && drafts.project.org === selected.org && drafts.project.project === selected.project ? drafts.project : null;
|
||||
const shownDraft = which => which === 'root' ? drafts.root : projectDraft();
|
||||
const field = (which, key) => esc(shownDraft(which)?.values?.[key] ?? '');
|
||||
const filled = d => !!d && Object.entries(d.values).some(([k, v]) => k !== 'form' && String(v).trim());
|
||||
|
||||
function markup() {
|
||||
if (!levels || levels.refused || levels.signedOut) return '';
|
||||
const off = !writable() || busy ? ' disabled' : '';
|
||||
const at = (which, current) => esc(shownDraft(which)?.revision ?? current);
|
||||
const key = (which, name) => ` data-focus-key="level-${which}-${name}"`;
|
||||
const chosen = v => (projectDraft()?.values?.form ?? 'define') === v ? ' selected' : '';
|
||||
const readOnly = writable() ? '' : '<p class="small">This Console was started without writes, so these levels are read only here. <code>mosaic agents root</code> and <code>mosaic project agents</code> change them from a terminal.</p>';
|
||||
const rootForm = writable() && root && !root.refused ? `<form id="agent-levels-root-form" class="level-form" data-revision="${at('root', root.revision)}"><h4>Add or change a root agent</h4><div class="filters"><label>Name <input name="name" maxlength="64" required autocomplete="off" value="${field('root', 'name')}"${key('root', 'name')}${off}></label><label>Definition <input name="definition" maxlength="64" autocomplete="off" placeholder="Unchanged" value="${field('root', 'definition')}"${key('root', 'definition')}${off}></label><label>Model <input name="model" maxlength="200" autocomplete="off" placeholder="Unchanged" value="${field('root', 'model')}"${key('root', 'model')}${off}></label></div><div class="filters"><label>Other variables, one KEY=VALUE per line <textarea name="vars" rows="2"${key('root', 'vars')}${off}>${field('root', 'vars')}</textarea></label><label>Remove variables, comma separated <input name="unset" autocomplete="off" value="${field('root', 'unset')}"${key('root', 'unset')}${off}></label><label>Not in organizations, comma separated, or none <input name="excludeOrgs" autocomplete="off" placeholder="Unchanged" value="${field('root', 'excludeOrgs')}"${key('root', 'excludeOrgs')}${off}></label></div><div class="settings-actions${filled(drafts.root) ? ' has-draft' : ''}"><button type="submit"${off}>Save root agent</button></div></form>` : '';
|
||||
const orgs = orgsOf(), projects = orgs.find(o => o.id === selected.org)?.projects ?? [];
|
||||
const pick = `<div class="filters"><label>Organization <select id="agent-levels-org"${busy ? ' disabled' : ''}>${orgs.map(o => `<option value="${esc(o.id)}"${o.id === selected.org ? ' selected' : ''}>${esc(o.id)}</option>`).join('')}</select></label><label>Project <select id="agent-levels-project"${busy ? ' disabled' : ''}>${projects.map(p => `<option value="${esc(p)}"${p === selected.project ? ' selected' : ''}>${esc(p)}</option>`).join('')}</select></label></div>`;
|
||||
const projectForm = writable() && project && !project.refused && !project.library ? `<form id="agent-levels-project-form" class="level-form" data-revision="${at('project', project.revision)}"><h4>Add or change a project entry</h4><div class="filters"><label>Agent <input name="name" maxlength="64" required autocomplete="off" value="${field('project', 'name')}"${key('project', 'name')}${off}></label><label>Entry <select name="form"${key('project', 'form')}${off}><option value="define"${chosen('define')}>Define a project agent</option><option value="narrow"${chosen('narrow')}>Narrow an inherited agent</option><option value="exclude"${chosen('exclude')}>Exclude an inherited agent</option></select></label><label>Definition, for a defined agent <input name="definition" maxlength="64" autocomplete="off" value="${field('project', 'definition')}"${key('project', 'definition')}${off}></label></div><label>Variables, one KEY=VALUE per line. A narrow takes limits.tools, limits.network and limits.authority only. <textarea name="vars" rows="2"${key('project', 'vars')}${off}>${field('project', 'vars')}</textarea></label><div class="settings-actions${filled(projectDraft()) ? ' has-draft' : ''}"><button type="submit"${off}>Save project entry</button></div></form>` : '';
|
||||
return `<div id="agent-levels-edit" data-agent-levels-edit><section class="panel" aria-labelledby="agent-levels-root-title"><h3 id="agent-levels-root-title">Root agents</h3><p>Root agents are in every organization unless a row excludes it. They are read from the instance's root agent file and are not launched yet.</p>${readOnly}${rootGrid()}${receiptLine('root')}${rootForm}</section><section class="panel" aria-labelledby="agent-levels-project-title"><h3 id="agent-levels-project-title">Project agents</h3><p>A project can define its own agents, narrow an inherited agent's limits, or exclude one. Project agents are not launched yet.</p>${orgs.length ? pick : '<p class="muted">No organization to show.</p>'}${orgs.length ? projectGrid() : ''}${receiptLine('project')}${projectForm}</section></div>`;
|
||||
}
|
||||
const repaint = () => { const box = $('agent-levels-edit'); if (box) box.outerHTML = markup(); };
|
||||
|
||||
async function section() {
|
||||
await load();
|
||||
return markup();
|
||||
}
|
||||
|
||||
async function post(url, payload) {
|
||||
const res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload), cache: 'no-store', redirect: 'manual' });
|
||||
if (res.type === 'opaqueredirect') return { signedOut: true };
|
||||
let body = null; try { body = await res.json(); } catch { /* unknown below */ }
|
||||
return { ok: res.ok, body };
|
||||
}
|
||||
const refusedText = body => body?.error === 'revision-conflict' ? 'Refused: the file changed since this page read it. The grid below was read again; check it and save again.'
|
||||
: typeof body?.message === 'string' ? `Refused (${body.error}): ${body.message}` : UNKNOWN;
|
||||
|
||||
// fromForm: the submitted form's draft is cleared on success. A row's Remove leaves it alone.
|
||||
async function write(which, url, payload, done, focus, fromForm = false) {
|
||||
busy = true; receipts[which] = ''; repaint();
|
||||
let answer;
|
||||
try { answer = await post(url, payload); } catch { answer = null; }
|
||||
busy = false; confirming = null;
|
||||
if (answer?.signedOut) { receipts[which] = 'Sign in again, then retry.'; repaint(); return false; }
|
||||
const ok = answer?.ok && answer.body?.outcome === 'done';
|
||||
receipts[which] = ok ? done(answer.body.receipt) : answer ? refusedText(answer.body) : UNKNOWN;
|
||||
const conflict = answer?.body?.error === 'revision-conflict';
|
||||
await load();
|
||||
if (ok && fromForm) drafts[which] = null;
|
||||
// After a conflict the grid is read again and the receipt asks for a check: the next save is a
|
||||
// deliberate one against what is shown now.
|
||||
if (conflict && fromForm && shownDraft(which)) shownDraft(which).revision = which === 'root' ? root?.revision : project?.revision;
|
||||
repaint();
|
||||
(focus && $(focus))?.focus?.();
|
||||
return ok;
|
||||
}
|
||||
// Take what a form holds into its draft, keeping the revision the draft began at.
|
||||
function capture(form) {
|
||||
const which = form.id === 'agent-levels-root-form' ? 'root' : 'project';
|
||||
const values = Object.fromEntries(new FormData(form));
|
||||
const old = shownDraft(which);
|
||||
drafts[which] = { ...(which === 'project' ? { org: selected.org, project: selected.project } : {}), revision: old?.revision ?? form.dataset.revision, values };
|
||||
form.querySelector('.settings-actions')?.classList.toggle('has-draft', filled(drafts[which]));
|
||||
return which;
|
||||
}
|
||||
|
||||
async function saveRoot(form) {
|
||||
const f = Object.fromEntries(new FormData(form));
|
||||
const name = String(f.name ?? '').trim(), entry = {};
|
||||
try {
|
||||
if (String(f.definition ?? '').trim()) entry.definition = String(f.definition).trim();
|
||||
const vars = parseVars(f.vars);
|
||||
if (String(f.model ?? '').trim()) { if (Object.hasOwn(vars, 'model')) throw new Error('Variable model is given twice.'); vars.model = String(f.model).trim(); }
|
||||
for (const key of list(f.unset)) { if (Object.hasOwn(vars, key)) throw new Error(`Variable ${key} is both set and removed.`); vars[key] = null; }
|
||||
if (Object.keys(vars).length) entry.vars = vars;
|
||||
if (String(f.excludeOrgs ?? '').trim()) entry.excludeOrgs = String(f.excludeOrgs).trim() === 'none' ? [] : list(f.excludeOrgs);
|
||||
if (!name) throw new Error('A root agent needs a name.');
|
||||
if (!Object.keys(entry).length) throw new Error('Nothing to change: give a definition, a variable or the organizations it is not in.');
|
||||
} catch (error) { receipts.root = error.message; capture(form); repaint(); return; }
|
||||
await write('root', '/api/agents/root', { revision: form.dataset.revision, name, entry },
|
||||
r => `Saved ${r.agent} at ${clock()}: ${r.changed.join(', ')}.`, 'agent-levels-root-title', true);
|
||||
}
|
||||
|
||||
async function saveProject(form) {
|
||||
const f = Object.fromEntries(new FormData(form));
|
||||
const name = String(f.name ?? '').trim();
|
||||
let entry;
|
||||
try {
|
||||
if (!name) throw new Error('A project entry needs an agent name.');
|
||||
const vars = parseVars(f.vars);
|
||||
if (f.form === 'exclude') { if (Object.keys(vars).length || String(f.definition ?? '').trim()) throw new Error('An exclude takes no definition and no variables.'); entry = { exclude: true }; }
|
||||
else if (f.form === 'narrow') { if (String(f.definition ?? '').trim()) throw new Error('A narrow takes no definition.'); if (!Object.keys(vars).length) throw new Error('A narrow needs at least one variable.'); entry = { vars }; }
|
||||
else { if (!String(f.definition ?? '').trim()) throw new Error('A defined agent needs a definition.'); entry = { definition: String(f.definition).trim(), ...(Object.keys(vars).length ? { vars } : {}) }; }
|
||||
} catch (error) { receipts.project = error.message; capture(form); repaint(); return; }
|
||||
await write('project', `/api/orgs/${enc(selected.org)}/agent-levels/projects/${enc(selected.project)}`, { revision: form.dataset.revision, name, entry },
|
||||
r => `Saved ${r.agent} in ${r.project} at ${clock()}: ${r.form}.`, 'agent-levels-project-title', true);
|
||||
}
|
||||
|
||||
async function remove(which, name) {
|
||||
if (confirming !== `${which}:${name}`) { confirming = `${which}:${name}`; repaint(); document.querySelector(`[data-level-remove="${which}"][data-level-name="${CSS.escape(name)}"]`)?.focus(); return; }
|
||||
if (which === 'root') await write('root', '/api/agents/root', { revision: root.revision, name, entry: null }, r => `Removed ${r.agent} at ${clock()}.`, 'agent-levels-root-title');
|
||||
else await write('project', `/api/orgs/${enc(selected.org)}/agent-levels/projects/${enc(selected.project)}`, { revision: project.revision, name, entry: null }, r => `Removed ${r.agent} from ${r.project} at ${clock()}.`, 'agent-levels-project-title');
|
||||
}
|
||||
|
||||
document.addEventListener('submit', e => {
|
||||
const form = e.target;
|
||||
if (form.id !== 'agent-levels-root-form' && form.id !== 'agent-levels-project-form') return;
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
capture(form);
|
||||
(form.id === 'agent-levels-root-form' ? saveRoot : saveProject)(form);
|
||||
});
|
||||
for (const kind of ['input', 'change']) document.addEventListener(kind, e => {
|
||||
const form = e.target?.closest?.('.level-form');
|
||||
if (form && e.target !== form) capture(form);
|
||||
});
|
||||
document.addEventListener('click', e => {
|
||||
const button = e.target.closest?.('[data-level-remove]');
|
||||
if (!button || button.disabled || busy || !writable()) return;
|
||||
remove(button.dataset.levelRemove, button.dataset.levelName);
|
||||
});
|
||||
document.addEventListener('change', async e => {
|
||||
if (e.target.id !== 'agent-levels-org' && e.target.id !== 'agent-levels-project') return;
|
||||
if (e.target.id === 'agent-levels-org') selected = { org: e.target.value, project: '' };
|
||||
else selected.project = e.target.value;
|
||||
receipts.project = ''; confirming = null;
|
||||
const id = e.target.id;
|
||||
await load(); repaint(); $(id)?.focus();
|
||||
});
|
||||
|
||||
// hasDraft feeds the Settings nav's unsaved-changes dot (bus.js settingsShell and its input listener).
|
||||
window.MosaicAgentLevels = Object.freeze({ section, hasDraft: () => filled(drafts.root) || filled(drafts.project) });
|
||||
})();
|
||||
@@ -1047,6 +1047,8 @@
|
||||
const unknownInstance='Outcome unknown. Read the role instances again before trying. Draft kept.';
|
||||
// The answer replaces the view the next render draws, so it must have the read's whole shape.
|
||||
const instanceAnswer=r=>/^[a-f0-9]{64}$/.test(r?.revision??'')&&typeof r.restart==='boolean'&&[r.instances,r.definitions].every(o=>o&&typeof o==='object'&&!Array.isArray(o));
|
||||
// #1873 A2: the level forms keep their own drafts (agents.js); here they only mark the nav.
|
||||
for(const kind of ['input','change'])document.addEventListener(kind,e=>{if(e.target?.closest?.('.level-form'))markSettingsDraft('agents',!!window.MosaicAgentLevels?.hasDraft());});
|
||||
for(const kind of ['input','change'])document.addEventListener(kind,e=>{const form=e.target?.closest?.('.instance-form');if(form){instanceDrafts[form.dataset.op]=instanceFields(form);markSettingsDraft('agents',true);form.querySelector('.settings-actions')?.classList.add('has-draft');const discard=form.querySelector('[data-instance-discard]');if(discard)discard.hidden=false;}});
|
||||
document.addEventListener('click',e=>{if(e.target.id!=='instances-reload' && e.target.dataset?.instanceDiscard===undefined)return;instanceDrafts={add:null,change:null};instanceView=null;instanceReceipt='';instanceReceipts=Object.create(null);instanceReceiptOp=null;last.delete('instances');render(true);});
|
||||
document.addEventListener('submit',async e=>{
|
||||
@@ -1200,7 +1202,7 @@
|
||||
function settingsShell(page, body, helper='') {
|
||||
const pages=isTester ? [['appearance','Appearance'],['business','Business'],['routing','Routing'],['agents','Agents'],['names','Seat names']] : settingsPages;
|
||||
const label=pages.find(([id])=>id===page)?.[1] || 'Appearance';
|
||||
const isDirty=id=>(id==='routing'&&routesDraft)||(id==='limits'&&settingsInputs)||(id==='assistant'&&(Object.keys(assistantDrafts).length||Object.keys(nameDrafts).length))||(id==='agents'&&(instanceDrafts.add||instanceDrafts.change));
|
||||
const isDirty=id=>(id==='routing'&&routesDraft)||(id==='limits'&&settingsInputs)||(id==='assistant'&&(Object.keys(assistantDrafts).length||Object.keys(nameDrafts).length))||(id==='agents'&&(instanceDrafts.add||instanceDrafts.change||window.MosaicAgentLevels?.hasDraft()));
|
||||
const dot='<span class="settings-dot"><span class="sr-only">unsaved changes</span></span>';
|
||||
const links=pages.map(([id,name],i)=>`${i===0?'<div class="settings-group">Your browser</div>':i===1?'<div class="settings-group">This instance</div>':''}<a href="#/settings/${id}"${id===page?' aria-current="page"':''}>${name}${isDirty(id)?dot:''}</a>`).join('') + (!isTester ? '<div class="settings-group">Members and invites <span class="muted">not built</span></div>' : '');
|
||||
return `${head('Settings','Your browser and this instance.')}<div class="settings-layout"><div><button type="button" id="settings-disclosure" aria-expanded="false" aria-controls="settings-nav"><small>Section</small><strong>${label}${isDirty(page)?dot:''}</strong><span aria-hidden="true">⌄</span></button><nav id="settings-nav" aria-label="Settings sections">${links}</nav></div><div class="settings-content settings-${page}${readonlyPage(page)?' readonly':''}"><h2 id="settings-page-title" tabindex="-1">${label}</h2><p class="settings-helper">${helper}</p>${readonlyPage(page)?'<p class="settings-readonly">Read only. Saving requires signed-in send mode.</p>':''}${body}</div></div>`;
|
||||
@@ -1217,10 +1219,13 @@
|
||||
if (page==='tokens') { try { [usage] = await get('usage'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; } try { limitRows = await get('limits'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; limitsRefused = true; } }
|
||||
if (page==='assistant'||page==='names') { try { names = await get('names'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; } }
|
||||
if (page==='agents'||page==='names'||page==='routing') { try { [instances] = await get('instances'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; } }
|
||||
// #1873 A2: the root and project agent levels (agents.js MosaicAgentLevels), the owner's only.
|
||||
let agentLevels='';
|
||||
if (page==='agents' && window.MosaicOrg && window.MosaicAgentLevels && !isTester) agentLevels = await window.MosaicAgentLevels.section().catch(()=>'');
|
||||
if (page==='routing') { try { [routes] = await get('routes'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; } }
|
||||
if (page==='tokens' && s.launch && !s.launch.refused) takeSnapshot(s.launch);
|
||||
const notify = typeof n.binding==='string' ? `<code>${txt(n.binding)}</code>` : n.refused ? `Could not be read: <code>${txt(n.refused)}</code>` : '<span class="muted">None set</span>';
|
||||
const contents={limits:()=>launchForm(s.launch),routing:()=>routesSection(routes,instances),agents:()=>agentsSection(instances),assistant:()=>assistantSection(s.launch,names),tokens:()=>tokenLimits(limitRows,limitsRefused)+tokenUsage(usage),names:()=>testerNames(names,instances),about:()=>`<section class="panel"><dl class="kv"><dt>Address</dt><dd><code>${txt(s.address)||'unknown'}</code></dd><dt>Release</dt><dd>${txt(s.release)||'unknown'}</dd><dt>Business</dt><dd>${txt(s.business)||none}</dd><dt>Board</dt><dd><code>${txt(s.board)||'unknown'}</code></dd><dt>Notifier binding</dt><dd>${notify}</dd></dl></section>`, 'model-proxy':()=>'<section class="panel"><a href="#/settings/model-proxy">Open model proxy settings</a></section>'};
|
||||
const contents={limits:()=>launchForm(s.launch),routing:()=>routesSection(routes,instances),agents:()=>agentsSection(instances)+agentLevels,assistant:()=>assistantSection(s.launch,names),tokens:()=>tokenLimits(limitRows,limitsRefused)+tokenUsage(usage),names:()=>testerNames(names,instances),about:()=>`<section class="panel"><dl class="kv"><dt>Address</dt><dd><code>${txt(s.address)||'unknown'}</code></dd><dt>Release</dt><dd>${txt(s.release)||'unknown'}</dd><dt>Business</dt><dd>${txt(s.business)||none}</dd><dt>Board</dt><dd><code>${txt(s.board)||'unknown'}</code></dd><dt>Notifier binding</dt><dd>${notify}</dd></dl></section>`, 'model-proxy':()=>'<section class="panel"><a href="#/settings/model-proxy">Open model proxy settings</a></section>'};
|
||||
const helpers={limits:'Leave a limit blank for no limit. Zero stops new launches. Running sessions keep running when limits or models change.',routing:"Each agent’s harness, model and thinking, and the fallback a launch takes when the primary harness fails its preflight. A fallback sets all three or none. Running sessions keep their route. The next launch reads the change.",agents:'Each agent with its definition, launch membership and runs. A launch refuses until the agent has a route in Routing.',assistant:'The definition stays an assistant one. Holder and credentials stay hand edits.',tokens:'Token limits the bus checks, and token use read from session transcripts.'};
|
||||
return settingsShell(page,contents[page](),`${get.oldest()?`Read ${when(get.oldest())}.`: 'Reading…'} ${helpers[page]||''}`);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
|
||||
const route = () => location.hash.replace(/^#\/orgs\/[^/]+/, '') || '/';
|
||||
// /api/workspace reads and writes across organizations (U1, U4b), so it keeps its own path.
|
||||
window.fetch = (input, init) => {
|
||||
if (typeof input === 'string' && input.startsWith('/api/') && !/^\/api\/(?:workspace(?:\/|\?|$)|orgs(?:\/|$)|themes(?:\?|$)|org-template(?:\?|$)|org-presets(?:\?|$)|org-admin(?:\?|$)|org-validate(?:\?|$)|agents\/levels(?:\?|$))/.test(input))
|
||||
if (typeof input === 'string' && input.startsWith('/api/') && !/^\/api\/(?:workspace(?:\/|\?|$)|orgs(?:\/|$)|themes(?:\?|$)|org-template(?:\?|$)|org-presets(?:\?|$)|org-admin(?:\?|$)|org-validate(?:\?|$)|agents\/(?:levels|root)(?:\?|$))/.test(input))
|
||||
input = `/api/orgs/${id}/${input.slice('/api/'.length)}`;
|
||||
return nativeFetch(input, init);
|
||||
};
|
||||
|
||||
@@ -294,7 +294,7 @@ button:disabled,[aria-disabled=true]{cursor:not-allowed}
|
||||
#agents-table tbody th,#routes-table tbody th{text-transform:none;font-size:14px;font-weight:600;color:var(--strong)}
|
||||
@media(max-width:759px){.settings-content .routing-panel{border:0;background:transparent;padding:0}.routing-panel .table-wrap{border:0;overflow:visible}.routing-panel #routes-table tr{display:grid;grid-template-columns:1fr 1fr;gap:0 10px;background:var(--surface)}.routing-panel #routes-table th,.routing-panel #routes-table td:first-of-type,.routing-panel #routes-table .fallback-cell:first-of-type{grid-column:1/-1}.routing-panel #routes-table td:nth-of-type(4){grid-column:1/-1}.routing-panel #routes-table td:nth-of-type(4)::before{content:'Fallback: None'}.routing-panel #routes-table .fallback-open td:nth-of-type(4)::before{content:'Fallback harness'}}
|
||||
.settings-content label:has(>input[type=checkbox]){display:inline-flex;align-items:center;gap:8px;min-height:44px;padding:0 8px}
|
||||
.settings-content .instance-form textarea,.settings-content .assistant-form textarea{display:block;width:100%}
|
||||
.settings-content :is(.instance-form,.level-form) textarea,.settings-content .assistant-form textarea{display:block;width:100%}
|
||||
.route-definition{display:none}.settings-spinner{display:inline-block;width:16px;height:16px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;vertical-align:middle;animation:settings-spin .8s linear infinite}@keyframes settings-spin{to{transform:rotate(360deg)}}@media(prefers-reduced-motion:reduce){.settings-spinner{animation:none}}
|
||||
@media(max-width:759px){#routes-table .route-definition{display:inline;float:right;color:var(--muted);font-size:12px;font-weight:400}}
|
||||
/* Settings forms: align controls across rows without changing their write paths. */
|
||||
@@ -302,15 +302,15 @@ button:disabled,[aria-disabled=true]{cursor:not-allowed}
|
||||
.settings-limits-table,.settings-model-table{width:100%;table-layout:fixed;border-collapse:collapse}
|
||||
.settings-limits-table col:first-child{width:45%}.settings-limits-table col:nth-child(2){width:144px}.settings-model-table col:first-child{width:25%}.settings-model-table col:nth-child(2),.settings-model-table col:nth-child(3){width:37.5%}
|
||||
.settings-limits-table :is(th,td),.settings-model-table :is(th,td){padding:8px;text-align:left;vertical-align:middle}.settings-limits-table tbody th,.settings-model-table tbody th{text-transform:none;font-size:14px;font-weight:500;color:var(--text)}.settings-limits-table .limit-group th{padding-top:14px;color:var(--muted);font-size:12px;font-weight:600}.settings-model-table input{width:100%}
|
||||
.settings-content .instance-form .filters{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto;gap:var(--gap);align-items:end}.settings-content .instance-form .filters>label:not(:has(input[type=checkbox])){display:flex;flex-direction:column;gap:6px}.settings-content .instance-form .filters>label:not(:has(input[type=checkbox])) :is(input,select){width:100%}.settings-content .instance-form .filters>label:has(input[type=checkbox]){display:flex;gap:8px;align-items:center;min-height:44px;white-space:nowrap}.settings-content .instance-form details{margin:var(--gap) 0}.hiring-grid>div{justify-content:flex-start;gap:var(--gap)}
|
||||
@media(max-width:759px){.settings-content .instance-form .filters{grid-template-columns:1fr}.settings-limits-table,.settings-limits-table tbody,.settings-limits-table tr,.settings-limits-table th,.settings-limits-table td,.settings-model-table,.settings-model-table tbody,.settings-model-table tr,.settings-model-table th,.settings-model-table td{display:block;width:100%}.settings-limits-table colgroup,.settings-model-table colgroup,.settings-limits-table thead,.settings-model-table thead{display:none}.settings-limits-table tbody tr:not(.limit-group){display:grid;grid-template-columns:120px 1fr;gap:0 12px;border-bottom:1px solid var(--line)}.settings-limits-table tbody tr:not(.limit-group)>th{grid-column:1/-1}.settings-limits-table :is(th,td),.settings-model-table :is(th,td){padding:6px 0}.settings-model-table tbody tr{padding:8px 0;border-bottom:1px solid var(--line)}.settings-model-table td:before{display:block;color:var(--muted);font-size:12px}.settings-model-table td:nth-of-type(1):before{content:'Provider'}.settings-model-table td:nth-of-type(2):before{content:'Model'}#routes-table .route-row:not(.fallback-open) td.fallback-cell:nth-of-type(n+5){display:none}}
|
||||
.settings-content :is(.instance-form,.level-form) .filters{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto;gap:var(--gap);align-items:end}.settings-content :is(.instance-form,.level-form) .filters>label:not(:has(input[type=checkbox])){display:flex;flex-direction:column;gap:6px}.settings-content :is(.instance-form,.level-form) .filters>label:not(:has(input[type=checkbox])) :is(input,select){width:100%}.settings-content :is(.instance-form,.level-form) .filters>label:has(input[type=checkbox]){display:flex;gap:8px;align-items:center;min-height:44px;white-space:nowrap}.settings-content :is(.instance-form,.level-form) details{margin:var(--gap) 0}.hiring-grid>div{justify-content:flex-start;gap:var(--gap)}
|
||||
@media(max-width:759px){.settings-content :is(.instance-form,.level-form) .filters{grid-template-columns:1fr}.settings-limits-table,.settings-limits-table tbody,.settings-limits-table tr,.settings-limits-table th,.settings-limits-table td,.settings-model-table,.settings-model-table tbody,.settings-model-table tr,.settings-model-table th,.settings-model-table td{display:block;width:100%}.settings-limits-table colgroup,.settings-model-table colgroup,.settings-limits-table thead,.settings-model-table thead{display:none}.settings-limits-table tbody tr:not(.limit-group){display:grid;grid-template-columns:120px 1fr;gap:0 12px;border-bottom:1px solid var(--line)}.settings-limits-table tbody tr:not(.limit-group)>th{grid-column:1/-1}.settings-limits-table :is(th,td),.settings-model-table :is(th,td){padding:6px 0}.settings-model-table tbody tr{padding:8px 0;border-bottom:1px solid var(--line)}.settings-model-table td:before{display:block;color:var(--muted);font-size:12px}.settings-model-table td:nth-of-type(1):before{content:'Provider'}.settings-model-table td:nth-of-type(2):before{content:'Model'}#routes-table .route-row:not(.fallback-open) td.fallback-cell:nth-of-type(n+5){display:none}}
|
||||
|
||||
/* Agent menus must extend beyond the table without being clipped by its scroll region. */
|
||||
.settings-agents .table-wrap{overflow:visible}
|
||||
|
||||
/* Review fixes: preserve readable actions, labels and targets at every width. */
|
||||
.settings-content .instance-form .filters>label:not(:has(input[type=checkbox])){align-items:flex-start}
|
||||
.settings-content .instance-form .filters>label:has(input[type=checkbox]){grid-column:1/-1;justify-self:start}
|
||||
.settings-content :is(.instance-form,.level-form) .filters>label:not(:has(input[type=checkbox])){align-items:flex-start}
|
||||
.settings-content :is(.instance-form,.level-form) .filters>label:has(input[type=checkbox]){grid-column:1/-1;justify-self:start}
|
||||
.settings-content .settings-actions .banner:not(:empty){order:-1;flex:1 0 100%;margin:0}
|
||||
.agent-mobile-label{display:none}
|
||||
#bus-view .page-head:has(+ .settings-layout) p{font-size:16px}
|
||||
@@ -337,6 +337,10 @@ button:disabled,[aria-disabled=true]{cursor:not-allowed}
|
||||
.s1-agents, .s1-authority, .s1-creds, #token-usage-table, #token-limits-table,
|
||||
.table-wrap:has(#proxy-accounts) table, .table-wrap:has(#proxy-keys) table { min-width:650px; }
|
||||
.s1-authority :is(th,td), .s1-creds :is(th,td) { white-space:nowrap; }
|
||||
/* #1873 A2: the root and project agent level grids scroll inside their card. */
|
||||
.table-wrap:has(#agent-levels-root-grid), .table-wrap:has(#agent-levels-project-grid) { overflow-x:scroll; max-width:100%; }
|
||||
#agent-levels-root-grid { min-width:650px; }
|
||||
#agent-levels-project-grid { min-width:480px; }
|
||||
}
|
||||
body:not(:has(.project-chat)) .content { padding-bottom:calc(22px + var(--foot-h)); }
|
||||
@media (max-width:759px) {
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
// #1873 A2: Settings > Agents, the root and project edit forms against their grids, in a real
|
||||
// browser on the real routes and writers (a temporary config directory with one organization).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
|
||||
import { loadRootAgents } from '../../organization/src/index.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { agentLevelsWriteRoutes } from '../src/agent-levels-write.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
|
||||
const SECRET = 'sk-test-NOT-A-SECRET-1234';
|
||||
const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
|
||||
|
||||
// orgSend gives the selected organization a send transport, so the role-instance Settings forms
|
||||
// are writable too and their handlers are live beside the level forms.
|
||||
async function open(t, { send = true, orgSend = false } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'ale-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const rolesDir = rolesCopy(root);
|
||||
const file = writeJson(join(root, 'businesses/demo.json'), businessDoc(root, 'demo'));
|
||||
const doc = () => JSON.parse(readFileSync(file, 'utf8'));
|
||||
const instances = { business: 'demo', revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {},
|
||||
instances: { chief: { definition: 'chief-of-staff', vars: {}, launched: true, launcher: false, protected: false, kept: false, running: [], alive: [] } } };
|
||||
const organizations = {
|
||||
list: async () => [{ id: 'demo', label: 'Demo', busHealthy: true }],
|
||||
members: async () => [{ id: 'demo', doc: doc() }],
|
||||
resolve: async id => id !== 'demo' ? null : {
|
||||
busHealthy: true, ...(orgSend ? { send: async () => ({}) } : {}),
|
||||
reads: { business: async () => ({ business: { id: 'demo', human: 'jason', arbiters: ['chief'], projects: ['stack'], vars: {}, instances: [{ instance: 'chief', vars: {} }] } }),
|
||||
settings: async () => ({ business: 'demo', release: 'fixture', notifier: {}, launch: { limits: {} } }), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
|
||||
bus: { instances: async () => instances, messages: async () => ({ messages: [], unread: 0 }), agents: async () => [], 'thread.claims': async () => [], inbox: async () => [], tasks: async () => [] },
|
||||
chat: { projects: async () => [{ id: 'stack', name: 'Stack' }], threads: async () => [] },
|
||||
},
|
||||
};
|
||||
const agentLevels = { loadRoot: () => loadRootAgents(root, { rolesDir }), rolesDir, ctx: { configDir: root, dataRoot: join(root, 'data'), instance: 'test' } };
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, agentLevels, send: send ? async () => ({}) : null, log: () => {},
|
||||
routes: [agentLevelsWriteRoutes({ configDir: root, rolesDir })] });
|
||||
t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
await b.viewport(1440, 900);
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/demo/settings/agents`);
|
||||
return { b, wait: waitFor(b), file, root };
|
||||
}
|
||||
const fill = (b, form, values) => b.evaluate(`(() => { const f = document.getElementById(${JSON.stringify(form)}); for (const [k, v] of Object.entries(${JSON.stringify(values)})) { f.elements[k].value = v; } f.requestSubmit(); })()`);
|
||||
// Typing: each value set, then input and change fired, as a person's edit fires them.
|
||||
const type = (b, form, values) => b.evaluate(`(() => { const f = document.getElementById(${JSON.stringify(form)}); for (const [k, v] of Object.entries(${JSON.stringify(values)})) { const el = f.elements[k]; el.value = v; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); } })()`);
|
||||
const values = (b, form) => b.evaluate(`Object.fromEntries(new FormData(document.getElementById(${JSON.stringify(form)})))`);
|
||||
const receipt = which => `(document.getElementById("agent-levels-${which}-receipt")?.textContent || "")`;
|
||||
|
||||
test('the root form adds a root agent and the grid repaints from a fresh read; a refusal names the key, keeps the draft and never shows the value', async t => {
|
||||
const { b, wait, root } = await open(t);
|
||||
await wait('document.getElementById("agent-levels-root-form")');
|
||||
assert.match(await b.evaluate('document.getElementById("agent-levels-root-grid").textContent'), /does not exist yet/);
|
||||
const before = await b.evaluate('document.getElementById("agent-levels-root-form").dataset.revision');
|
||||
await fill(b, 'agent-levels-root-form', { name: 'researcher', definition: 'research', model: 'claude-opus-5-5', vars: 'limits.network=none', excludeOrgs: 'home' });
|
||||
await wait('document.querySelector("[data-root-agent=\\"researcher\\"]")');
|
||||
await wait(`${receipt('root')}.startsWith("Saved researcher at ")`);
|
||||
const row = await b.evaluate('document.querySelector("[data-root-agent=\\"researcher\\"]").innerText');
|
||||
assert.match(row, /research/); assert.match(row, /claude-opus-5-5/); assert.match(row, /limits\.network/); assert.match(row, /home/);
|
||||
assert.doesNotMatch(row, /\bnone\b/, 'only the model value is shown');
|
||||
assert.notEqual(await b.evaluate('document.getElementById("agent-levels-root-form").dataset.revision'), before, 'the form carries the new revision');
|
||||
assert.equal(await b.evaluate('document.getElementById("agent-levels-root-form").elements.name.value'), '', 'a saved form is cleared');
|
||||
// A value the root file can't take: the key is named, the value is not, the draft stays.
|
||||
await fill(b, 'agent-levels-root-form', { name: 'researcher', definition: '', model: SECRET.repeat(10), vars: '', excludeOrgs: '' });
|
||||
await wait(`${receipt('root')}.startsWith("Refused (agents-invalid)")`);
|
||||
const page = await b.evaluate('document.getElementById("agent-levels-edit").innerText');
|
||||
assert.match(page, /model/); assert.ok(!page.includes(SECRET), 'the value never shows outside the input');
|
||||
assert.equal(await b.evaluate('document.getElementById("agent-levels-root-form").elements.model.value'), SECRET.repeat(10));
|
||||
assert.doesNotMatch(readFileSync(join(root, 'agents.json'), 'utf8'), /NOT-A-SECRET/);
|
||||
// Remove asks once more, then the row goes.
|
||||
await b.evaluate('document.querySelector("[data-level-remove=root][data-level-name=researcher]").click()');
|
||||
await wait('document.querySelector("[data-level-remove=root][data-level-name=researcher]").textContent === "Confirm remove"');
|
||||
await b.evaluate('document.querySelector("[data-level-remove=root][data-level-name=researcher]").click()');
|
||||
await wait(`!document.querySelector("[data-root-agent=\\"researcher\\"]") && ${receipt('root')}.startsWith("Removed researcher")`);
|
||||
});
|
||||
|
||||
test('the project form defines, narrows and excludes, each row repainting with its form; a widening narrow is refused without the value', async t => {
|
||||
const { b, wait, file } = await open(t);
|
||||
await wait('document.getElementById("agent-levels-project-form")');
|
||||
assert.equal(await b.evaluate('document.getElementById("agent-levels-org").value'), 'demo');
|
||||
assert.equal(await b.evaluate('document.getElementById("agent-levels-project").value'), 'stack');
|
||||
assert.match(await b.evaluate('document.getElementById("agent-levels-project-grid").textContent'), /No project entries/);
|
||||
await fill(b, 'agent-levels-project-form', { name: 'checker', form: 'define', definition: 'fact-checker', vars: 'model=claude-sonnet-5-5' });
|
||||
await wait('document.querySelector("[data-project-agent=checker][data-form=define]")');
|
||||
await wait(`${receipt('project')}.startsWith("Saved checker in stack at ")`);
|
||||
await fill(b, 'agent-levels-project-form', { name: 'coder', form: 'narrow', definition: '', vars: 'limits.network=none' });
|
||||
await wait('document.querySelector("[data-project-agent=coder][data-form=narrow]")');
|
||||
await fill(b, 'agent-levels-project-form', { name: 'reviewer', form: 'exclude', definition: '', vars: '' });
|
||||
await wait('document.querySelector("[data-project-agent=reviewer][data-form=exclude]")');
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-project-agent]")].map(r => r.dataset.projectAgent + ":" + r.dataset.form)'),
|
||||
['checker:define', 'coder:narrow', 'reviewer:exclude']);
|
||||
assert.deepEqual(Object.keys(JSON.parse(readFileSync(file, 'utf8')).projects.stack.agents).sort(), ['checker', 'coder', 'reviewer']);
|
||||
const before = readFileSync(file, 'utf8');
|
||||
await fill(b, 'agent-levels-project-form', { name: 'coder', form: 'narrow', definition: '', vars: `model=${SECRET}` });
|
||||
await wait(`${receipt('project')}.startsWith("Refused (project-agents-invalid)")`);
|
||||
assert.match(await b.evaluate(receipt('project')), /can only narrow/);
|
||||
assert.equal(await b.evaluate('document.getElementById("agent-levels-project-form").elements.vars.value'), `model=${SECRET}`, 'a refused form keeps the draft');
|
||||
assert.ok(!(await b.evaluate('document.getElementById("agent-levels-edit").innerText')).includes(SECRET));
|
||||
assert.equal(readFileSync(file, 'utf8'), before);
|
||||
// A client-side refusal names the line, never its text.
|
||||
await fill(b, 'agent-levels-project-form', { name: 'coder', form: 'narrow', definition: '', vars: SECRET });
|
||||
await wait(`${receipt('project')} === "Variables line 1 needs KEY=VALUE."`);
|
||||
await b.evaluate('document.querySelector("[data-level-remove=project][data-level-name=checker]").click()');
|
||||
await b.evaluate('document.querySelector("[data-level-remove=project][data-level-name=checker]").click()');
|
||||
await wait(`!document.querySelector("[data-project-agent=checker]") && ${receipt('project')}.startsWith("Removed checker from stack")`);
|
||||
});
|
||||
|
||||
test('without --send both levels read but offer no form', async t => {
|
||||
const { b, wait } = await open(t, { send: false });
|
||||
await wait('document.getElementById("agent-levels-edit")');
|
||||
assert.match(await b.evaluate('document.getElementById("agent-levels-edit").innerText'), /started without writes/);
|
||||
assert.equal(await b.evaluate('!!document.getElementById("agent-levels-root-form") || !!document.getElementById("agent-levels-project-form")'), false);
|
||||
});
|
||||
|
||||
test('both drafts survive a refresh render and the other form\'s save; a draft keeps its revision; the role-instance handlers leave the level forms alone', async t => {
|
||||
const { b, wait, root } = await open(t, { orgSend: true });
|
||||
await wait('document.getElementById("agent-levels-root-form") && document.getElementById("agent-levels-project-form")');
|
||||
assert.equal(await b.evaluate('!!document.getElementById("instance-add-form")'), true, 'the role-instance forms are writable in this fixture');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#agent-levels-edit .instance-form").length'), 0);
|
||||
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#agent-levels-root-form .filters")).display'), 'grid', 'the level forms keep the Settings form layout');
|
||||
await type(b, 'agent-levels-root-form', { name: 'drafted', vars: 'limits.network=none' });
|
||||
await type(b, 'agent-levels-project-form', { name: 'helper', form: 'narrow', vars: 'limits.tools=read' });
|
||||
assert.equal(await b.evaluate('!!document.querySelector("#settings-nav a[href$=\'settings/agents\'] .settings-dot")'), true, 'the nav marks unsaved changes');
|
||||
// A full Settings render, as the ten-second poll does: the edit box is a new node, the drafts are in it.
|
||||
await b.evaluate('document.getElementById("agent-levels-edit").dataset.old = "1"; document.getElementById("refresh").click()');
|
||||
await wait('document.getElementById("agent-levels-edit") && !document.getElementById("agent-levels-edit").dataset.old');
|
||||
assert.deepEqual(await values(b, 'agent-levels-root-form'), { name: 'drafted', definition: '', model: '', vars: 'limits.network=none', unset: '', excludeOrgs: '' });
|
||||
assert.deepEqual(await values(b, 'agent-levels-project-form'), { name: 'helper', form: 'narrow', definition: '', vars: 'limits.tools=read' });
|
||||
// The root form saves; the project draft stays as typed and the root draft clears.
|
||||
await type(b, 'agent-levels-root-form', { name: 'researcher', definition: 'research', vars: '' });
|
||||
await b.evaluate('document.getElementById("agent-levels-root-form").requestSubmit()');
|
||||
await wait(`${receipt('root')}.startsWith("Saved researcher at ")`);
|
||||
assert.equal(await b.evaluate('document.getElementById("agent-levels-root-form").elements.name.value'), '');
|
||||
assert.deepEqual(await values(b, 'agent-levels-project-form'), { name: 'helper', form: 'narrow', definition: '', vars: 'limits.tools=read' });
|
||||
// A locally refused submit keeps its draft; the role-instance handler takes no part.
|
||||
await type(b, 'agent-levels-root-form', { name: 'second', vars: 'malformed' });
|
||||
await b.evaluate('document.getElementById("agent-levels-root-form").requestSubmit()');
|
||||
await wait(`${receipt('root')} === "Variables line 1 needs KEY=VALUE."`);
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
assert.equal(await b.evaluate('document.getElementById("agent-levels-root-form").elements.name.value'), 'second');
|
||||
assert.equal(await b.evaluate('document.getElementById("agent-levels-root-form").elements.vars.value'), 'malformed');
|
||||
assert.equal(await b.evaluate('!!document.querySelector("#instance-add-form .settings-actions.has-draft")'), false, 'the instance form took no draft');
|
||||
// The file changes under a draft: the draft's save sends the revision it began at and is refused,
|
||||
// then a second, deliberate save goes through against the file as now read.
|
||||
await type(b, 'agent-levels-root-form', { name: 'later', definition: 'research', vars: '' });
|
||||
const began = await b.evaluate('document.getElementById("agent-levels-root-form").dataset.revision');
|
||||
writeFileSync(join(root, 'agents.json'), JSON.stringify({ agentsVersion: 1, agents: {} }) + '\n', { mode: 0o600 });
|
||||
await b.evaluate('document.getElementById("agent-levels-edit").dataset.old = "1"; document.getElementById("refresh").click()');
|
||||
await wait('document.getElementById("agent-levels-edit") && !document.getElementById("agent-levels-edit").dataset.old');
|
||||
assert.equal(await b.evaluate('document.getElementById("agent-levels-root-form").dataset.revision'), began, 'the draft keeps the revision it began at');
|
||||
await b.evaluate('document.getElementById("agent-levels-root-form").requestSubmit()');
|
||||
await wait(`${receipt('root')}.startsWith("Refused: the file changed")`);
|
||||
assert.equal(await b.evaluate('document.getElementById("agent-levels-root-form").elements.name.value'), 'later');
|
||||
await b.evaluate('document.getElementById("agent-levels-root-form").requestSubmit()');
|
||||
await wait(`${receipt('root')}.startsWith("Saved later at ")`);
|
||||
assert.deepEqual(Object.keys(JSON.parse(readFileSync(join(root, 'agents.json'), 'utf8')).agents), ['later']);
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
// #1873 A2: the owner's Settings > Agents writes, POST /api/agents/root and
|
||||
// POST /api/orgs/<org>/agent-levels/projects/<project>, through serve.mjs's hook fence with the real
|
||||
// root and organization writers on a temporary config directory.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, rmSync, existsSync } from 'node:fs';
|
||||
import { request } from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { agentLevelsWriteRoutes } from '../src/agent-levels-write.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
|
||||
const SECRET = 'sk-test-NOT-A-SECRET-1234';
|
||||
const ABSENT = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
|
||||
|
||||
// `who` is the session the fence sees: the owner, a granted user (the hook fence's owner flag
|
||||
// false), or nobody. `send` is the Console's --send.
|
||||
async function fixture(t, { send = true, granted = false } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'alw-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const rolesDir = rolesCopy(root);
|
||||
const file = writeJson(join(root, 'businesses/demo.json'), businessDoc(root, 'demo'));
|
||||
const trail = [];
|
||||
const session = { provider: 'fixture', iss: 'https://issuer.example', sub: 'owner-sub', name: 'jason' };
|
||||
const sso = { publicOrigin: '', publicHost: '', formTargets: () => [], session: () => session, handle: () => {}, health: () => ({}) };
|
||||
const organizations = {
|
||||
list: async () => [{ id: 'demo', label: 'Demo', busHealthy: true }],
|
||||
resolve: async id => id === 'demo' ? { busHealthy: true } : null,
|
||||
members: async () => [],
|
||||
...(granted ? { grantFor: () => null } : {}),
|
||||
};
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, send: send ? async () => ({}) : null, organizations, log: () => {},
|
||||
routes: [agentLevelsWriteRoutes({ configDir: root, rolesDir, trail: entry => trail.push(entry) })] });
|
||||
t.after(() => close(web));
|
||||
const base = `http://127.0.0.1:${web.address().port}`;
|
||||
sso.publicOrigin = base; sso.publicHost = new URL(base).host;
|
||||
return { base, root, file, trail };
|
||||
}
|
||||
|
||||
function call(f, path, method = 'GET', body, headers = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const data = body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body);
|
||||
const req = request(f.base + path, { method, headers: { ...(data !== undefined ? { origin: f.base, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } : {}), ...headers } }, res => {
|
||||
let text = ''; res.on('data', chunk => { text += chunk; }); res.on('end', () => resolve([res.statusCode, JSON.parse(text), text]));
|
||||
});
|
||||
req.on('error', reject); req.setTimeout(10000, () => req.destroy(new Error('fixture request timed out'))); req.end(data);
|
||||
});
|
||||
}
|
||||
const ROOT = '/api/agents/root';
|
||||
const PROJECT = '/api/orgs/demo/agent-levels/projects/stack';
|
||||
|
||||
test('the owner adds, patches and removes a root agent; each write answers a receipt and a trail entry with no value', async t => {
|
||||
const f = await fixture(t);
|
||||
const [s0, read0] = await call(f, ROOT);
|
||||
assert.equal(s0, 200); assert.deepEqual(read0, { revision: ABSENT, state: 'absent', agents: [], writable: true });
|
||||
const [s1, added, text1] = await call(f, ROOT, 'POST', { revision: ABSENT, name: 'researcher', entry: { definition: 'research', vars: { model: 'claude-opus-5-5' } } });
|
||||
assert.equal(s1, 200, text1);
|
||||
assert.equal(added.outcome, 'done'); assert.equal(added.receipt.verb, 'root.agent.set'); assert.deepEqual(added.receipt.changed, ['added']);
|
||||
assert.equal(added.receipt.revisionBefore, ABSENT);
|
||||
assert.doesNotMatch(text1, /claude-opus/);
|
||||
const [, read1] = await call(f, ROOT);
|
||||
assert.equal(read1.revision, added.receipt.revisionAfter);
|
||||
assert.deepEqual(read1.agents, [{ name: 'researcher', definition: 'research', model: 'claude-opus-5-5', varKeys: [], excludeOrgs: [] }]);
|
||||
const [s2, patched] = await call(f, ROOT, 'POST', { revision: read1.revision, name: 'researcher', entry: { vars: { model: null, 'limits.network': 'none' }, excludeOrgs: ['home'] } });
|
||||
assert.equal(s2, 200); assert.deepEqual([...patched.receipt.changed].sort(), ['excludeOrgs', 'vars.limits.network', 'vars.model']);
|
||||
const [s3, removed] = await call(f, ROOT, 'POST', { revision: patched.receipt.revisionAfter, name: 'researcher', entry: null });
|
||||
assert.equal(s3, 200); assert.equal(removed.receipt.verb, 'root.agent.remove');
|
||||
assert.deepEqual(f.trail.map(e => [e.action, e.agent, e.outcome]), [['agents.root.set', 'researcher', 'done'], ['agents.root.set', 'researcher', 'done'], ['agents.root.remove', 'researcher', 'done']]);
|
||||
assert.ok(f.trail.every(e => e.by?.sub === 'owner-sub' && e.by.iss === 'https://issuer.example'));
|
||||
const trailText = JSON.stringify(f.trail) + readFileSync(join(f.root, 'agents-receipts.jsonl'), 'utf8');
|
||||
assert.doesNotMatch(trailText, /claude-opus|"none"|home"/);
|
||||
});
|
||||
|
||||
test('refusals: wrong method, cross-site, missing Origin, non-owner, read-only, oversized, bad keys, stale revision', async t => {
|
||||
const f = await fixture(t);
|
||||
const ok = { revision: ABSENT, name: 'researcher', entry: { definition: 'research' } };
|
||||
// Only GET and POST are registered: serve.mjs answers any other method before a writer runs.
|
||||
for (const method of ['PUT', 'PATCH', 'DELETE']) {
|
||||
for (const path of [ROOT, PROJECT]) {
|
||||
const [status, body] = await call(f, path, method, ok);
|
||||
assert.ok(status === 403 || status === 404 || status === 405, `${method} ${path}: ${status}`); assert.ok(body.error, method);
|
||||
}
|
||||
}
|
||||
assert.equal((await call(f, ROOT, 'POST', ok, { 'sec-fetch-site': 'cross-site' }))[0], 403);
|
||||
assert.equal((await call(f, ROOT, 'POST', ok, { origin: 'http://evil.example' }))[0], 403);
|
||||
assert.equal((await call(f, PROJECT, 'POST', ok, { 'sec-fetch-site': 'same-site' }))[0], 403);
|
||||
const [big, bigBody] = await call(f, ROOT, 'POST', { ...ok, entry: { definition: 'research', vars: { pad: 'x'.repeat(5000) } } });
|
||||
assert.equal(big, 413); assert.equal(bigBody.error, 'invalid-request');
|
||||
for (const bad of [{ ...ok, extra: 1 }, { revision: 'nope', name: 'researcher', entry: null }, { ...ok, name: '../x' }, { ...ok, entry: { definition: 'research', root: '/srv' } }, { ...ok, entry: { vars: [] } }]) {
|
||||
const [status, body] = await call(f, ROOT, 'POST', bad);
|
||||
assert.equal(status, 400, JSON.stringify(bad)); assert.equal(body.error, 'invalid-request');
|
||||
}
|
||||
assert.equal((await call(f, ROOT, 'POST', 'not json'))[0], 400);
|
||||
assert.equal(existsSync(join(f.root, 'agents.json')), false, 'no refusal wrote the file');
|
||||
const [, made] = await call(f, ROOT, 'POST', ok);
|
||||
const before = readFileSync(join(f.root, 'agents.json'), 'utf8');
|
||||
const [stale, staleBody] = await call(f, ROOT, 'POST', { ...ok, entry: { definition: 'reviewer' } });
|
||||
assert.equal(stale, 409); assert.equal(staleBody.error, 'revision-conflict');
|
||||
// An invalid value: the key is named, the value never travels.
|
||||
const [invalid, invalidBody, invalidText] = await call(f, ROOT, 'POST', { revision: made.receipt.revisionAfter, name: 'researcher', entry: { vars: { model: SECRET.repeat(10) } } });
|
||||
assert.equal(invalid, 400); assert.equal(invalidBody.error, 'agents-invalid'); assert.match(invalidBody.message, /model/);
|
||||
assert.ok(!invalidText.includes(SECRET), invalidText);
|
||||
const [chief, chiefBody] = await call(f, ROOT, 'POST', { revision: made.receipt.revisionAfter, name: 'boss', entry: { definition: 'chief-of-staff' } });
|
||||
assert.equal(chief, 400); assert.match(chiefBody.message, /chief-of-staff/);
|
||||
assert.equal(readFileSync(join(f.root, 'agents.json'), 'utf8'), before);
|
||||
assert.ok(!JSON.stringify(f.trail).includes(SECRET));
|
||||
|
||||
const readOnly = await fixture(t, { send: false });
|
||||
const [ro, roBody] = await call(readOnly, ROOT, 'POST', ok);
|
||||
assert.equal(ro, 403); assert.equal(roBody.error, 'read-only');
|
||||
const [, roRead] = await call(readOnly, ROOT);
|
||||
assert.equal(roRead.writable, false);
|
||||
});
|
||||
|
||||
test('a non-owner session never reaches the writers', async t => {
|
||||
const f = await fixture(t);
|
||||
const ok = { revision: ABSENT, name: 'researcher', entry: { definition: 'research' } };
|
||||
// The fence: a Host other than the named one.
|
||||
assert.equal((await call(f, ROOT, 'POST', ok, { host: 'other.example' }))[0], 403);
|
||||
// The route itself, as the fence calls it for a granted user or the tester listener (owner false),
|
||||
// and for no session at all.
|
||||
const handlers = [];
|
||||
agentLevelsWriteRoutes({ configDir: f.root, rolesDir: join(f.root, 'roles') })({ add: (method, route, handler) => handlers.push([method, route, handler]) });
|
||||
const session = { provider: 'fixture', iss: 'https://issuer.example', sub: 'owner-sub' };
|
||||
for (const [method, route, handler] of handlers.filter(([m]) => m === 'POST')) {
|
||||
const path = typeof route === 'string' ? route : PROJECT;
|
||||
for (const context of [{ path, orgId: 'demo', session, owner: false, send: true }, { path, orgId: 'demo', session: null, owner: true, send: true }]) {
|
||||
const res = { status: 0, body: null, writeHead(status) { this.status = status; }, end(text) { this.body = JSON.parse(text); } };
|
||||
await handler({ method, headersDistinct: {}, [Symbol.asyncIterator]: async function* () {} }, res, context);
|
||||
assert.equal(res.status, 403, `${path} ${JSON.stringify(context)}`); assert.equal(res.body.error, 'owner-only');
|
||||
}
|
||||
}
|
||||
assert.equal(existsSync(join(f.root, 'agents.json')), false);
|
||||
});
|
||||
|
||||
test('project entries: define, narrow, exclude and remove repaint the read; a widening narrow, the chief and a stale revision refuse', async t => {
|
||||
const f = await fixture(t);
|
||||
const [, r0] = await call(f, ROOT);
|
||||
await call(f, ROOT, 'POST', { revision: r0.revision, name: 'scout', entry: { definition: 'research' } });
|
||||
const [s0, p0] = await call(f, PROJECT);
|
||||
assert.equal(s0, 200); assert.deepEqual({ ...p0, revision: undefined }, { organization: 'demo', project: 'stack', revision: undefined, library: false, agents: [], writable: true });
|
||||
const set = async (name, entry) => { const [, now] = await call(f, PROJECT); return call(f, PROJECT, 'POST', { revision: now.revision, name, entry }); };
|
||||
const [d, define, defineText] = await set('checker', { definition: 'fact-checker', vars: { model: 'claude-sonnet-5-5' } });
|
||||
assert.equal(d, 200, defineText);
|
||||
assert.equal(define.receipt.verb, 'project.agents.set'); assert.equal(define.receipt.form, 'define');
|
||||
assert.equal(define.receipt.revisionBefore, p0.revision); assert.notEqual(define.receipt.revisionAfter, p0.revision);
|
||||
assert.doesNotMatch(defineText, new RegExp(f.root.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
assert.equal((await set('coder', { vars: { 'limits.network': 'none' } }))[1].receipt.form, 'narrow');
|
||||
assert.equal((await set('scout', { exclude: true }))[1].receipt.form, 'exclude');
|
||||
const [, listed] = await call(f, PROJECT);
|
||||
assert.deepEqual(listed.agents, [
|
||||
{ name: 'checker', form: 'define', definition: 'fact-checker', varKeys: ['model'] },
|
||||
{ name: 'coder', form: 'narrow', varKeys: ['limits.network'] },
|
||||
{ name: 'scout', form: 'exclude' },
|
||||
]);
|
||||
const before = readFileSync(f.file, 'utf8');
|
||||
const [w, widen, widenText] = await set('coder', { vars: { model: SECRET } });
|
||||
assert.equal(w, 400); assert.equal(widen.error, 'project-agents-invalid'); assert.match(widen.message, /can only narrow/);
|
||||
assert.ok(!widenText.includes(SECRET));
|
||||
for (const name of ['chief', 'assistant']) {
|
||||
const [c, chief] = await set(name, { exclude: true });
|
||||
assert.equal(c, 400, name); assert.equal(chief.error, 'project-agents-invalid');
|
||||
}
|
||||
const [stale, staleBody] = await call(f, PROJECT, 'POST', { revision: p0.revision, name: 'checker', entry: null });
|
||||
assert.equal(stale, 409); assert.equal(staleBody.error, 'revision-conflict');
|
||||
const [missing, missingBody] = await call(f, '/api/orgs/demo/agent-levels/projects/nope', 'POST', { revision: listed.revision, name: 'scout', entry: { exclude: true } });
|
||||
assert.equal(missing, 404); assert.equal(missingBody.error, 'project-not-found');
|
||||
assert.equal((await call(f, '/api/orgs/other/agent-levels/projects/stack'))[0], 404);
|
||||
// A name the writers can't take is a 400 that names the field, never the value, on both routes.
|
||||
for (const name of ['BadName', 'a'.repeat(65)]) {
|
||||
for (const [route, entry] of [[PROJECT, { definition: 'research' }], [ROOT, { definition: 'research' }]]) {
|
||||
const [s, body, text] = await call(f, route, 'POST', { revision: listed.revision, name, entry });
|
||||
assert.equal(s, 400, `${route} ${name}: ${text}`); assert.equal(body.error, 'invalid-request'); assert.match(body.message, /^name must be an id/);
|
||||
assert.ok(!text.includes(name), text);
|
||||
}
|
||||
}
|
||||
assert.equal(readFileSync(f.file, 'utf8'), before);
|
||||
for (const name of ['checker', 'coder', 'scout']) assert.equal((await set(name, null))[1].receipt.form, 'remove');
|
||||
assert.deepEqual((await call(f, PROJECT))[1].agents, []);
|
||||
const forms = f.trail.filter(e => e.action === 'project.agents.set').map(e => `${e.agent}:${e.form}:${e.outcome}`);
|
||||
assert.deepEqual(forms.slice(0, 3), ['checker:define:done', 'coder:narrow:done', 'scout:exclude:done']);
|
||||
assert.ok(forms.includes('coder:narrow:project-agents-invalid'));
|
||||
assert.ok(f.trail.every(e => !JSON.stringify(e).includes(SECRET) && !JSON.stringify(e).includes('claude-sonnet')));
|
||||
});
|
||||
@@ -45,6 +45,7 @@
|
||||
"policy.change",
|
||||
"prd.approve",
|
||||
"decision.resolve.technical",
|
||||
"decision.resolve.delivery",
|
||||
"library.write"
|
||||
],
|
||||
"vars": {
|
||||
@@ -97,6 +98,7 @@
|
||||
"policy.change": "gated",
|
||||
"prd.approve": "gated",
|
||||
"decision.resolve.technical": "gated",
|
||||
"decision.resolve.delivery": "within",
|
||||
"library.write": "gated"
|
||||
},
|
||||
"refused": null
|
||||
@@ -144,6 +146,7 @@
|
||||
"policy.change": "gated",
|
||||
"prd.approve": "gated",
|
||||
"decision.resolve.technical": "within",
|
||||
"decision.resolve.delivery": "gated",
|
||||
"library.write": "gated"
|
||||
},
|
||||
"refused": null
|
||||
@@ -192,6 +195,7 @@
|
||||
"policy.change": "gated",
|
||||
"prd.approve": "gated",
|
||||
"decision.resolve.technical": "gated",
|
||||
"decision.resolve.delivery": "gated",
|
||||
"library.write": "gated"
|
||||
},
|
||||
"refused": null
|
||||
@@ -237,6 +241,7 @@
|
||||
"policy.change": "gated",
|
||||
"prd.approve": "gated",
|
||||
"decision.resolve.technical": "gated",
|
||||
"decision.resolve.delivery": "gated",
|
||||
"library.write": "gated"
|
||||
},
|
||||
"refused": null
|
||||
@@ -281,7 +286,8 @@
|
||||
"account.create": "gated",
|
||||
"policy.change": "gated",
|
||||
"prd.approve": "gated",
|
||||
"decision.resolve.technical": "gated",
|
||||
"decision.resolve.technical": "within",
|
||||
"decision.resolve.delivery": "within",
|
||||
"library.write": "gated"
|
||||
},
|
||||
"refused": null
|
||||
|
||||
+5
-4
@@ -38,10 +38,11 @@ Within your grants, act on what you see without waiting to be asked, and
|
||||
raise a decision or send a message for anything outside them.
|
||||
|
||||
Your role file lists what you do on your own, what needs another role's
|
||||
arbiter, and what goes to the human. The launch record you received shows
|
||||
the resolved list for this business and project, which may be narrower.
|
||||
Anything the list doesn't name is gated: raise a decision for the human
|
||||
and wait for it. Never route around a refusal.
|
||||
arbiter, and what goes to the human. The "Your rails" prompt section shows
|
||||
the resolved list for this business and project, which may be narrower. Call
|
||||
`my_rails` for your current rails. Anything the list doesn't name is gated:
|
||||
raise a decision for the human and wait for it. Never route around a
|
||||
refusal.
|
||||
|
||||
Handing a task to someone else, or changing its scope, is cross-role.
|
||||
Raise it as a decision for the delivery arbiter.
|
||||
|
||||
+5
-4
@@ -43,10 +43,11 @@ Within your grants, act on what you see without waiting to be asked, and
|
||||
raise a decision or send a message for anything outside them.
|
||||
|
||||
Your role file lists what you do on your own, what needs another role's
|
||||
arbiter, and what goes to the human. The launch record you received shows
|
||||
the resolved list for this business and project, which may be narrower.
|
||||
Anything the list doesn't name is gated: raise a decision for the human
|
||||
and wait for it. Never route around a refusal.
|
||||
arbiter, and what goes to the human. The "Your rails" prompt section shows
|
||||
the resolved list for this business and project, which may be narrower. Call
|
||||
`my_rails` for your current rails. Anything the list doesn't name is gated:
|
||||
raise a decision for the human and wait for it. Never route around a
|
||||
refusal.
|
||||
|
||||
Changing a task's scope is cross-role. Raise it as a decision for the
|
||||
delivery arbiter.
|
||||
|
||||
+5
-5
@@ -50,11 +50,11 @@ Within your grants, act on what you see without waiting to be asked, and
|
||||
raise a decision or send a message for anything outside them.
|
||||
|
||||
Your role file grants one action, `git.merge.protected`, as cross-role:
|
||||
every landing needs a decision resolved by the technical arbiter. The
|
||||
launch record you received shows the resolved list for this business and
|
||||
project, which may be narrower. You can't push, review, send messages or
|
||||
change a task. For anything else, raise a decision and wait for it. Never
|
||||
route around a refusal.
|
||||
every landing needs a decision resolved by the technical arbiter. The "Your
|
||||
rails" prompt section shows the resolved list for this business and project,
|
||||
which may be narrower. Call `my_rails` for your current rails. You can't
|
||||
push, review, send messages or change a task. For anything else, raise a
|
||||
decision and wait for it. Never route around a refusal.
|
||||
|
||||
## Protocol
|
||||
|
||||
|
||||
+4
-4
@@ -108,10 +108,10 @@ A `role.revoke` raise whose target is the assistant is a hard wait too:
|
||||
it goes to the human, cited or not.
|
||||
|
||||
Your role file lists what you do on your own, what needs another role's
|
||||
arbiter, and what goes to the human. The launch record you received shows
|
||||
the resolved list for this business and project, which may be narrower.
|
||||
Anything the list doesn't name is gated: raise a decision and wait for
|
||||
it. Never route around a refusal.
|
||||
arbiter, and what goes to the human. The "Your rails" prompt section shows
|
||||
the resolved list for this business and project, which may be narrower. Call
|
||||
`my_rails` for your current rails. Anything the list doesn't name is gated:
|
||||
raise a decision and wait for it. Never route around a refusal.
|
||||
|
||||
A blocking decision holds a task: it needs `task_ref`, and the task
|
||||
waits for the answer. A decision with no task is non-blocking, and an
|
||||
|
||||
+5
-4
@@ -35,10 +35,11 @@ Within your grants, act on what you see without waiting to be asked, and
|
||||
raise a decision or send a message for anything outside them.
|
||||
|
||||
Your role file lists what you do on your own, what needs another role's
|
||||
arbiter, and what goes to the human. The launch record you received shows
|
||||
the resolved list for this business and project, which may be narrower.
|
||||
Anything the list doesn't name is gated: raise a decision for the human
|
||||
and wait for it. Never route around a refusal.
|
||||
arbiter, and what goes to the human. The "Your rails" prompt section shows
|
||||
the resolved list for this business and project, which may be narrower. Call
|
||||
`my_rails` for your current rails. Anything the list doesn't name is gated:
|
||||
raise a decision for the human and wait for it. Never route around a
|
||||
refusal.
|
||||
|
||||
Your verdict gates the work in the queue and on the bus. An approval in
|
||||
Gitea is advisory.
|
||||
|
||||
@@ -24,11 +24,11 @@ code you review, and you have no shell and no write tools.
|
||||
Within your grants, act on what you see without waiting to be asked, and
|
||||
raise a decision or send a message for anything outside them.
|
||||
|
||||
Your role file grants the verdict and nothing else. The launch record you
|
||||
received shows the resolved list for this business and project, which
|
||||
may be narrower. You can't push, merge, send messages or change a task.
|
||||
For anything else, raise a decision and wait for it. Never route around a
|
||||
refusal.
|
||||
Your role file grants the verdict and nothing else. The "Your rails" prompt
|
||||
section shows the resolved list for this business and project, which may be
|
||||
narrower. Call `my_rails` for your current rails. You can't push, merge,
|
||||
send messages or change a task. For anything else, raise a decision and wait
|
||||
for it. Never route around a refusal.
|
||||
|
||||
Your verdict gates the work in the queue and on the bus. An approval in
|
||||
Gitea is advisory.
|
||||
|
||||
Reference in new issue
Block a user