Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82dcba2d45 | ||
|
|
29f7c3af85 | ||
|
|
17a48217f6 | ||
|
|
9d00f9ad54 | ||
|
|
a3e82efe23 | ||
|
|
c8ff8ea128 | ||
|
|
ddf4f895cd | ||
|
|
7035ce61b3 | ||
|
|
54bf239e32 |
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;
|
||||
}
|
||||
}
|
||||
@@ -1217,6 +1220,11 @@ export class Broker {
|
||||
this.#businesses[name] = next;
|
||||
this.#scheduleRequests();
|
||||
}
|
||||
// #1873 A3: host-only, for the launch boundary's admission: the code that refuses `instance` in code
|
||||
// project `repo` under the business's current document (the one the last reload swapped in), or null.
|
||||
projectBarred(business, instance, repo) {
|
||||
return this.#projectRefused(this.#business(business), instance, repo)?.code ?? null;
|
||||
}
|
||||
// Host-only current role resolver shared by the broker and built-in task tracker.
|
||||
taskRoles(business) {
|
||||
const roles = this.#business(business).roles;
|
||||
@@ -1531,11 +1539,40 @@ export class Broker {
|
||||
this.#processingWork();
|
||||
const b = this.#business(s.business);
|
||||
if ((b.retired ?? []).includes(instance)) fail('instance-retired');
|
||||
this.#copyRefused(b, instance);
|
||||
if (!Object.hasOwn(b.roles, instance)) fail('unknown-role');
|
||||
if (this.#threaded(s.business, instance)) fail(thread);
|
||||
if (this.#store.get('SELECT state FROM launch_state WHERE business=?', s.business)?.state === 'revoked') fail('launch-revoked');
|
||||
if (this.#firing(s.business, instance)) fail('instance-firing');
|
||||
}
|
||||
// #1873 A3: a root or project copy the boot left unbound or refused isn't in `roles`. Its refusal
|
||||
// names the agent and the missing services or the reason, never a value (organization
|
||||
// agent-copies.mjs copyRefusal; inlined because this module also loads in the sandbox client).
|
||||
#copyRefused(b, instance) {
|
||||
const copy = b.copies && Object.hasOwn(b.copies, instance) ? b.copies[instance] : null;
|
||||
if (!copy || copy.state === 'instance') return;
|
||||
if (copy.state === 'unbound') throw new BusError('credential-unbound', `${instance} has no binding in ${b.id} for ${copy.missing.join(', ')}`);
|
||||
throw new BusError(copy.project === null ? 'copy-refused' : 'project-refused', `${instance} can't launch in ${b.id}: ${copy.reason}`);
|
||||
}
|
||||
// #1873 A3: why `instance` can't work in code project `repo`, as a BusError, or null when it can
|
||||
// (organization agent-copies.mjs projectRefusal, inlined for the same reason). A project copy works
|
||||
// only in its own project; in a project whose union refuses no agent works; an instance the
|
||||
// project excludes doesn't work there. The reason names the instance, the project and why.
|
||||
#projectRefused(b, instance, repo) {
|
||||
const copy = b.copies && Object.hasOwn(b.copies, instance) ? b.copies[instance] : null;
|
||||
if (copy && copy.project !== null && copy.project !== repo)
|
||||
return new BusError('project-agent-elsewhere', `${instance} works only in project ${copy.project}`);
|
||||
const union = repo === null || !b.unions || !Object.hasOwn(b.unions, repo) ? null : b.unions[repo];
|
||||
if (!union) return null;
|
||||
if (!union.ok) return new BusError('project-refused', `${instance} can't work in project ${repo}: project ${repo} refuses its agents: ${union.reason}`);
|
||||
if (!union.agents.includes(instance)) return new BusError('project-refused', `${instance} can't work in project ${repo}: the project excludes it`);
|
||||
return null;
|
||||
}
|
||||
// #1873 A3: the union refusal of code project `repo`, as a BusError naming the project and why, or null.
|
||||
#unionRefused(b, repo) {
|
||||
const union = repo === null || !b.unions || !Object.hasOwn(b.unions, repo) ? null : b.unions[repo];
|
||||
return union && !union.ok ? new BusError('project-refused', `project ${repo} refuses its agents: ${union.reason}`) : null;
|
||||
}
|
||||
// W-N1 (webby-0014): a fire of I stopped its runs, and no fire of I has finished since.
|
||||
#firing(business, instance) {
|
||||
return !!this.#store.get(
|
||||
@@ -1849,7 +1886,15 @@ export class Broker {
|
||||
this.#revokeLost('access-expiry', true, doc);
|
||||
rows = this.#threadRows('t.business=?', business);
|
||||
}
|
||||
return rows.map(({ owner, project, ...r }) => ({ ...r, ...(this.#archived(business, project) ? { archived: true } : {}), ...(lost(r, owner, project) ? { revoked: true } : {}) }));
|
||||
// #1873 A3: a thread whose instance no longer works in its project's code project (the project's
|
||||
// union refuses, or it excludes the instance) says so, so the launcher refuses its launch.
|
||||
const b = this.#business(business);
|
||||
const refusedIn = (instance, project) => {
|
||||
if (!b.unions && !b.copies) return null;
|
||||
const e = this.#projectRefused(b, instance, this.#store.get('SELECT repo FROM projects WHERE id=? AND business=?', project, business)?.repo ?? null);
|
||||
return e ? { projectRefused: { code: e.code, reason: e.reason } } : null;
|
||||
};
|
||||
return rows.map(({ owner, project, ...r }) => ({ ...r, ...(this.#archived(business, project) ? { archived: true } : {}), ...(lost(r, owner, project) ? { revoked: true } : {}), ...refusedIn(r.instance, project) }));
|
||||
}
|
||||
// Row 121 round 2 (#1615, Dewey's F3 in 28591): the launcher's idle stop. The tick decides
|
||||
// from a threadsState snapshot, and a message the run took after it would be delivered to a
|
||||
@@ -6107,6 +6152,12 @@ export class Broker {
|
||||
entry.running = this.#running(s.business, name);
|
||||
entry.alive = this.#aliveAfterFire(s.business, name);
|
||||
}
|
||||
// #1873 A3: the copies this bus booted with, and each one's running runs. The file's own
|
||||
// instances above carry no copy; a copy carries its state, missing service names or reason.
|
||||
if (request.verb === "instances" && result?.instances && typeof result.instances === "object" && this.#business(s.business).copies)
|
||||
result.copies = Object.fromEntries(Object.entries(this.#business(s.business).copies).map(([name, c]) => [name, {
|
||||
...structuredClone(c), running: c.state === 'instance' ? this.#running(s.business, name) : [],
|
||||
}]));
|
||||
return result;
|
||||
}
|
||||
return this.#store.transaction(() => {
|
||||
@@ -6938,6 +6989,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');
|
||||
@@ -8023,7 +8081,17 @@ export class Broker {
|
||||
// #797 MVP3: an archived project takes no new room threads and no new requests; reading it
|
||||
// (room.read) and every existing record stay open.
|
||||
if ((opening || sending) && this.#archived(s.business, project)) fail('project-archived');
|
||||
const members = Object.keys(b.roles).filter((instance) => this.#threaded(s.business, instance)).sort();
|
||||
// #1873 A3: the room's members are the thread instances that work in its code project. A project
|
||||
// whose union refuses takes no new room thread or request, naming the project and why; its
|
||||
// existing threads stay readable.
|
||||
const repo = this.#store.get('SELECT repo FROM projects WHERE id=? AND business=?', project, s.business)?.repo ?? null;
|
||||
const unionRefused = this.#unionRefused(b, repo);
|
||||
if ((opening || sending) && unionRefused) throw unionRefused;
|
||||
const members = Object.keys(b.roles).filter((instance) => {
|
||||
if (!this.#threaded(s.business, instance)) return false;
|
||||
const refused = this.#projectRefused(b, instance, repo);
|
||||
return !refused || (unionRefused !== null && refused.code === 'project-refused');
|
||||
}).sort();
|
||||
if (!members.length) fail('thread-instance-missing');
|
||||
const owner = this.#owner(s);
|
||||
// Preflight all collisions/missing threads before open creates or send writes anything.
|
||||
@@ -8111,7 +8179,13 @@ export class Broker {
|
||||
if (!s.tester && !s.user) this.#human(s);
|
||||
keys(a, ['project', 'name', 'instance'], ['project', 'name']);
|
||||
if (!name(a.name)) fail('invalid-request');
|
||||
const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
|
||||
// #1873 A3: a root or project copy takes a thread only when the caller names it, so leaving the
|
||||
// instance out still chooses among the organization file's own thread instances.
|
||||
const copies = b.copies ?? {};
|
||||
// #1873 A3: the code project the chat project maps to decides which agents work in it.
|
||||
const repo = this.#store.get('SELECT repo FROM projects WHERE id=? AND business=?', identifier(a.project), s.business)?.repo ?? null;
|
||||
const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r) &&
|
||||
(a.instance !== undefined || (!Object.hasOwn(copies, r) && !this.#projectRefused(b, r, repo))));
|
||||
const instance = a.instance === undefined
|
||||
? (instances.length === 1 ? instances[0] : null)
|
||||
: identifier(a.instance);
|
||||
@@ -8125,9 +8199,18 @@ export class Broker {
|
||||
fail('project-not-found');
|
||||
// #797 MVP3: an archived project refuses new threads (and the room's requests, above).
|
||||
if (this.#archived(s.business, identifier(a.project))) fail('project-archived');
|
||||
// #1873 A3: a named copy the boot left unbound or refused says why, and a project whose union
|
||||
// refuses takes no thread, naming the project and why.
|
||||
if (a.instance !== undefined && instance) this.#copyRefused(b, instance);
|
||||
const unionRefused = this.#unionRefused(b, repo);
|
||||
if (unionRefused) throw unionRefused;
|
||||
// The human selects an instance; omission is compatible only with one candidate.
|
||||
if (!instances.length || (a.instance !== undefined && !instances.includes(instance))) fail('thread-instance-missing');
|
||||
if (a.instance === undefined && instances.length > 1) fail('thread-instance-ambiguous');
|
||||
// #1873 A3: a project agent works only in its own project, and an agent the project excludes
|
||||
// doesn't work in it.
|
||||
const elsewhere = this.#projectRefused(b, instance, repo);
|
||||
if (elsewhere) throw elsewhere;
|
||||
if (this.#store.get('SELECT 1 FROM threads WHERE project=? AND name=?', a.project, a.name)) fail('thread-exists');
|
||||
const tid = randomUUID();
|
||||
this.#store.run(
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -88,7 +88,7 @@ function launchOp(m) {
|
||||
if (m.op === 'request.release') return runtime.requestRelease(m.cap, m.record);
|
||||
if (m.op === 'request.manifest') return runtime.requestManifest(m.record);
|
||||
if (m.op === 'request.chief') return runtime.requestChief(m.business);
|
||||
if (m.op === 'request.admission') return runtime.admission({ business: m.business, project: m.project, projects: m.projects, thread: m.thread, session: m.session });
|
||||
if (m.op === 'request.admission') return runtime.admission({ business: m.business, project: m.project, projects: m.projects, instance: m.instance, revision: m.revision, thread: m.thread, session: m.session });
|
||||
if (m.op === 'credentialStatus') return runtime.credentialStatus(m.business, m.role);
|
||||
if (m.op === 'modelAdmission') return runtime.modelAdmission(m);
|
||||
if (m.op === 'modelPreflight') return runtime.modelPreflight(m);
|
||||
|
||||
@@ -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;
|
||||
@@ -35,7 +35,7 @@ import { instanceSettings } from '../../organization/src/instance-settings.mjs';
|
||||
import { organizationWriter, OrganizationError, setReplyLimits } from '../../organization/src/organization.mjs';
|
||||
import { getProjectRemote } from '../../organization/src/project-remotes.mjs';
|
||||
import { credentialVerbs } from './credential-verbs.mjs';
|
||||
import { loadBusiness } from '../../organization/src/organization-config.mjs';
|
||||
import { loadWithCopies } from '../../organization/src/agent-copies.mjs';
|
||||
import { loadRole } from '../../organization/src/role.mjs';
|
||||
import { resolveInstance } from '../../organization/src/resolve.mjs';
|
||||
import { canonicalJson } from '../../organization/src/util.mjs';
|
||||
@@ -284,7 +284,14 @@ export async function startBroker({
|
||||
builtinBinding = null,
|
||||
modelCatalogue = null, // in-process fixture seam; boot JSON cannot supply a service
|
||||
makeProviderLoader = createLoader, // in-process fixture seam; boot JSON cannot supply a function
|
||||
// #1873 A3: the boot's root and project agent copies (cli config.mjs copiesAtBoot), plain data. Every
|
||||
// reload of the organization file below re-applies this snapshot, never the root file.
|
||||
copies = null,
|
||||
}) {
|
||||
if (copies !== null && (typeof copies !== 'object' || Array.isArray(copies) || typeof copies.org !== 'string'
|
||||
|| !Object.hasOwn(businesses ?? {}, copies.org) || copies.copies === null || typeof copies.copies !== 'object' || Array.isArray(copies.copies)))
|
||||
throw new BusError('invalid-host-request');
|
||||
const loadOrg = id => loadWithCopies(id, { dir: organization.dir, rolesDir: organization.rolesDir, snapshot: copies?.org === id ? copies : null });
|
||||
if (!Array.isArray(consoleOwners) || consoleOwners.length > 512 || consoleOwners.some((o) => o === null || typeof o !== 'object' || Array.isArray(o) ||
|
||||
Object.keys(o).length !== 3 || ['provider', 'iss', 'sub'].some((k) => typeof o[k] !== 'string' || !o[k] || o[k].length > 512)))
|
||||
throw new BusError('invalid-host-request');
|
||||
@@ -564,7 +571,7 @@ export async function startBroker({
|
||||
const reloadLog = entry => process.stderr.write(`organization: ${JSON.stringify(entry)}\n`);
|
||||
function reload(id) {
|
||||
try {
|
||||
const b = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
|
||||
const b = loadOrg(id);
|
||||
const resolved = {};
|
||||
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: b, instance });
|
||||
const next = withoutPending(id, busOrganization(b, resolved));
|
||||
@@ -595,7 +602,7 @@ export async function startBroker({
|
||||
if (!found || found.after !== organization_revision) refuse('bootstrap-receipt-mismatch');
|
||||
// Only this bootstrap's own post-add revision may be admitted: any later organization write means the confirmed world moved (#800).
|
||||
if (revision !== found.after) refuse('bootstrap-revision-stale');
|
||||
const b = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
|
||||
const b = loadOrg(id);
|
||||
const resolved = {};
|
||||
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: b, instance });
|
||||
const full = busOrganization(b, resolved);
|
||||
@@ -622,7 +629,11 @@ export async function startBroker({
|
||||
// product the broker holds is ready only while it has a complete marker and a committed activate receipt: the
|
||||
// answer is read from both on every call, so a marker removed after the activation closes it again, and the
|
||||
// broker's reload now follows both readiness records (#802).
|
||||
function admission({ business: id, project, projects, thread, session }) {
|
||||
// With `instance` (#1873 A3), the launch boundary also asks whether that instance may work in each
|
||||
// project as the business stands now: a project that excludes it, or whose union refuses, is not ready.
|
||||
// `instance` comes with `revision`, the organization file revision the launch resolved its bundle
|
||||
// from; a file that has changed since (a narrow, a project define's vars, an exclude) is not ready.
|
||||
function admission({ business: id, project, projects, instance, revision: launched, thread, session }) {
|
||||
if (projects !== undefined) {
|
||||
const no = code => ({ ready: false, revision: null, code });
|
||||
if (typeof id !== 'string' || !Object.hasOwn(businesses, id) || !Array.isArray(projects) || projects.some(p => typeof p !== 'string')) return no('project-unknown');
|
||||
@@ -631,9 +642,21 @@ export async function startBroker({
|
||||
if (!row) return no('session-not-found');
|
||||
if (row.archived) return no('project-archived');
|
||||
}
|
||||
if (instance !== undefined && (typeof instance !== 'string' || typeof launched !== 'string')) return no('project-unknown');
|
||||
if (instance !== undefined) {
|
||||
let now;
|
||||
try { now = writer?.read(id).revision; } catch { return no('organization-unreadable'); }
|
||||
if (now === undefined) return no('organization-unreadable');
|
||||
if (now !== launched) return no('organization-changed');
|
||||
}
|
||||
for (const p of projects) {
|
||||
const result = admission({ business: id, project: p });
|
||||
if (!result.ready) return result;
|
||||
if (instance !== undefined) {
|
||||
// The broker holds the document the last writer reload swapped in; `businesses` is the boot one.
|
||||
const barred = broker.projectBarred(id, instance, p);
|
||||
if (barred) return no(barred);
|
||||
}
|
||||
}
|
||||
return { ready: true, revision: null, code: null };
|
||||
}
|
||||
@@ -753,7 +776,7 @@ export async function startBroker({
|
||||
// plugins.set reloads the business's limit checks in the background.
|
||||
organization:settings?(proxyOrganization?.wrap ?? (operation => operation))((id,verb,args,actor,context)=>{
|
||||
if (verb === 'project.remote.get.console' || verb === 'project.remote.set.console') {
|
||||
const current = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
|
||||
const current = loadOrg(id);
|
||||
const resolved = {};
|
||||
for (const instance of Object.keys(current.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: current, instance });
|
||||
const live = withoutPending(id, busOrganization(current, resolved));
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// #1873 Phase A slice A3: the broker half of root and project agent copies. The business carries the
|
||||
// boot's `copies` view (organization agent-copies.mjs withCopies): a bound copy is also in `roles`,
|
||||
// an unbound or refused one is not. The cli half, with real runners, is cli agent-copies.test.mjs.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { BusError, Broker } from '../src/broker.mjs';
|
||||
|
||||
const shipped = (name) => JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
|
||||
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
|
||||
const SECRET = 'sk-test-NOT-A-SECRET-1234';
|
||||
|
||||
const copy = (name, definition, project, state, extra = {}) => ({ name, level: project === null ? 'root' : 'project', project, definition, state, ...extra });
|
||||
const demo = () => ({
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'chief' },
|
||||
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging }, companion: { messaging: shipped('companion').messaging } },
|
||||
projects: { stack: { root: '/nonexistent/stack' }, web: { root: '/nonexistent/web' }, docs: { root: '/nonexistent/docs' } },
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', ...none },
|
||||
pm: { definition: 'pm', ...none },
|
||||
assistant: { definition: 'assistant', ...none },
|
||||
gate: { definition: 'merge-gate', ...none },
|
||||
'companion.stack': { definition: 'companion', ...none },
|
||||
buddy: { definition: 'companion', ...none },
|
||||
},
|
||||
copies: {
|
||||
gate: copy('gate', 'merge-gate', null, 'instance'),
|
||||
buddy: copy('buddy', 'companion', null, 'instance'),
|
||||
'companion.stack': copy('companion', 'companion', 'stack', 'instance'),
|
||||
researcher: copy('researcher', 'research', null, 'unbound', { missing: ['gitea'] }),
|
||||
wide: copy('wide', 'merge-gate', null, 'refused', { reason: 'model <value> is not in launch.models' }),
|
||||
'lint.web': copy('lint', 'security-reviewer', 'web', 'refused', { reason: 'project web refuses its agents: ghost is excluded, but nothing defines it' }),
|
||||
},
|
||||
// Each code project's union as withCopies computes it: web excludes buddy, docs refuses.
|
||||
unions: {
|
||||
stack: { ok: true, agents: ['assistant', 'buddy', 'chief', 'companion.stack', 'gate', 'pm'] },
|
||||
web: { ok: true, agents: ['assistant', 'chief', 'gate', 'pm'] },
|
||||
docs: { ok: false, reason: 'projects.docs.agents.ghost: names no root or organization agent to exclude' },
|
||||
},
|
||||
});
|
||||
|
||||
function setup(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-copies-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const b = new Broker({ store, businesses: { demo: demo() } });
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const call = (verb, args = {}) => b.request(human, { verb, args });
|
||||
const refusal = (fn) => {
|
||||
try { fn(); } catch (e) { assert.ok(e instanceof BusError, String(e)); return e; }
|
||||
assert.fail('no refusal');
|
||||
};
|
||||
const trail = () => store.all('SELECT kind, body FROM events').map((e) => `${e.kind} ${e.body}`).join('\n');
|
||||
return { b, store, call, refusal, trail };
|
||||
}
|
||||
|
||||
test('A3 bus: a bound copy takes a launch request; an unbound or refused one refuses at the verb by name, with the code alone in the trail', (t) => {
|
||||
const f = setup(t);
|
||||
assert.match(f.call('instance.launch', { instance: 'gate' }).request, /\S/);
|
||||
const unbound = f.refusal(() => f.call('instance.launch', { instance: 'researcher' }));
|
||||
assert.deepEqual([unbound.code, unbound.reason], ['credential-unbound', 'researcher has no binding in demo for gitea']);
|
||||
const refused = f.refusal(() => f.call('instance.launch', { instance: 'wide' }));
|
||||
assert.equal(refused.code, 'copy-refused');
|
||||
assert.match(refused.reason, /^wide can't launch in demo: /);
|
||||
const project = f.refusal(() => f.call('instance.launch', { instance: 'lint.web' }));
|
||||
assert.equal(project.code, 'project-refused');
|
||||
assert.match(project.reason, /^lint\.web can't launch in demo: project web refuses its agents: /);
|
||||
// Regenerate takes the same check.
|
||||
assert.equal(f.refusal(() => f.call('instance.regenerate', { instance: 'researcher' })).code, 'credential-unbound');
|
||||
// A name that is no copy and no instance is still unknown.
|
||||
assert.equal(f.refusal(() => f.call('instance.launch', { instance: 'nobody' })).code, 'unknown-role');
|
||||
// The trail holds each refusal's code, never its reason.
|
||||
const trail = f.trail();
|
||||
assert.match(trail, /credential-unbound/);
|
||||
for (const text of ['has no binding', "can't launch", 'refuses its agents', SECRET]) assert.equal(trail.includes(text), false, text);
|
||||
});
|
||||
|
||||
test('A3 bus: a project agent takes a thread only in its own project, and only when named', (t) => {
|
||||
const f = setup(t);
|
||||
const stack = f.call('project.create', { name: 'stack work', repo: 'stack' }).id;
|
||||
const web = f.call('project.create', { name: 'web work', repo: 'web' }).id;
|
||||
const loose = f.call('project.create', { name: 'loose' }).id;
|
||||
// Unnamed, the organization file's own thread instance is still the one choice.
|
||||
const plain = f.call('thread.create', { project: stack, name: 'plain' }).id;
|
||||
assert.equal(f.store.get('SELECT instance FROM threads WHERE id=?', plain).instance, 'assistant');
|
||||
const own = f.call('thread.create', { project: stack, name: 'own', instance: 'companion.stack' });
|
||||
assert.match(own.id, /\S/);
|
||||
for (const project of [web, loose])
|
||||
assert.equal(f.refusal(() => f.call('thread.create', { project, name: 'elsewhere', instance: 'companion.stack' })).code, 'project-agent-elsewhere');
|
||||
// A named copy the boot refused says why, before the thread instance check.
|
||||
const unbound = f.refusal(() => f.call('thread.create', { project: stack, name: 'r', instance: 'researcher' }));
|
||||
assert.deepEqual([unbound.code, unbound.reason], ['credential-unbound', 'researcher has no binding in demo for gitea']);
|
||||
});
|
||||
|
||||
test('A3 bus: a room holds only the thread instances that work in its project; an excluded agent takes no thread there; a refused union takes none at all, naming the project', (t) => {
|
||||
const f = setup(t);
|
||||
const project = (repo) => f.call('project.create', { name: `${repo ?? 'loose'} work`, ...(repo ? { repo } : {}) }).id;
|
||||
const [stack, web, docs, loose] = [project('stack'), project('web'), project('docs'), project(null)];
|
||||
const members = (id) => f.call('room.open', { project: id }).threads.map((x) => x.instance).sort();
|
||||
// Another project's agent never joins, and the project's own exclusion holds.
|
||||
assert.deepEqual(members(stack), ['assistant', 'buddy', 'companion.stack']);
|
||||
assert.deepEqual(members(web), ['assistant']);
|
||||
assert.deepEqual(members(loose), ['assistant', 'buddy']);
|
||||
assert.deepEqual(f.call('room.send', { project: web, body: 'hello' }).to, ['assistant']);
|
||||
const excluded = f.refusal(() => f.call('thread.create', { project: web, name: 'b', instance: 'buddy' }));
|
||||
assert.deepEqual([excluded.code, excluded.reason], ['project-refused', "buddy can't work in project web: the project excludes it"]);
|
||||
// docs's union refuses: no thread, named or not, and no room, each naming docs and why.
|
||||
for (const fn of [
|
||||
() => f.call('thread.create', { project: docs, name: 'plain' }),
|
||||
() => f.call('thread.create', { project: docs, name: 'a', instance: 'assistant' }),
|
||||
() => f.call('room.open', { project: docs }),
|
||||
]) {
|
||||
const e = f.refusal(fn);
|
||||
assert.equal(e.code, 'project-refused');
|
||||
assert.match(e.reason, /^project docs refuses its agents: .*ghost/);
|
||||
}
|
||||
assert.equal(f.trail().includes('refuses its agents'), false, 'the trail holds the code only');
|
||||
});
|
||||
@@ -37,7 +37,9 @@ const baselines = {
|
||||
// #1839: artifact Console replies alone may use 2 MiB; the proof and other limits are unchanged.
|
||||
// #1873 U1: humanCall, busCaller, userCall and userCaller take an optional signal and absolute
|
||||
// deadline; userCall shares transport(). Nonces, argv and limits are unchanged.
|
||||
"packages/webui/src/bus.mjs": "3ef8064dd7911f9eee4475b52c74a1d551583a065474b1bcee968cd45f722747",
|
||||
// #1873 A3: STATUS maps credential-unbound, copy-refused, project-refused and project-agent-elsewhere
|
||||
// to 409; no proof or transport change.
|
||||
"packages/webui/src/bus.mjs": "d8f3e0eb5299fdb192e98a1238ca693aae7c36af9a21de72d5f5d39610589377",
|
||||
};
|
||||
const repoFile = path => new URL("../../../" + path, import.meta.url);
|
||||
|
||||
|
||||
@@ -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-copies.mjs (rename design r2, stage B).
|
||||
export * from "../../organization/src/agent-copies.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);
|
||||
}
|
||||
|
||||
@@ -658,7 +658,7 @@ async function bus(rest, io, deps) {
|
||||
// tracker gets (the bus runtime makes the same choice, runtime.mjs trackerOf).
|
||||
const taskModule = boot.businesses?.[businessId]?.plugins?.["task-tracker"];
|
||||
const tracker = Boolean(boot.trackers?.[businessId]) || taskModule === undefined || taskModule === "store";
|
||||
const launcher = createLauncher({ host, system: { ...system, instanceRoot: system.dataRoot, dataRoot: org.root }, env: launchEnv, tracker, launches, ...(instance ? fixtureLauncherOptions({ system, env: launchEnv, instance, doc: orgDoc }) : {}), ...(deps.launcher ?? {}) });
|
||||
const launcher = createLauncher({ host, system: { ...system, instanceRoot: system.dataRoot, dataRoot: org.root }, env: launchEnv, tracker, launches, copies: boot.copies ?? null, ...(instance ? fixtureLauncherOptions({ system, env: launchEnv, instance, doc: orgDoc }) : {}), ...(deps.launcher ?? {}) });
|
||||
await launcher.listen();
|
||||
if (instance) io.stdout.write(`DEV LOCAL OWNER MODE: console owner local-dev accepted (named instance ${instance})\n`);
|
||||
if (instance && parsed.flags.has("--pm")) io.stdout.write('DEV LOCAL OWNER MODE: launched agents run as this OS user and can reach the loopback Console as owner; use this instance for UI testing only\n');
|
||||
|
||||
@@ -10,6 +10,10 @@ import { dirname, isAbsolute, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { managedState } from "../../organization/src/project-bootstrap.mjs";
|
||||
import { BusinessError, accessFilePath, configDir, loadAccess, loadBusiness, loadProject, projectFilePath, resolveInstance, systemVars } from "../../organization/src/index.mjs";
|
||||
import { loadRootAgents } from "../../organization/src/agent-levels.mjs";
|
||||
import { materializeCopies, withCopies } from "../../organization/src/agent-copies.mjs";
|
||||
import { organizationWriter } from "../../organization/src/organization.mjs";
|
||||
import { businessFilePath } from "../../organization/src/organization-config.mjs";
|
||||
import { busOrganization } from "../../bus/src/organization-context.mjs";
|
||||
import { BusError } from "../../bus/src/broker.mjs";
|
||||
import { CliError } from "./errors.mjs";
|
||||
@@ -86,7 +90,7 @@ function trackerFor(system, b, warn, dataRoot) {
|
||||
if (dataRoot && managedState({ dataRoot, business: b.id, project: id, entry }) === 'reserved') continue;
|
||||
if (!existsSync(projectFilePath(entry.root))) continue;
|
||||
const project = loadProject(entry.root);
|
||||
if (project.id !== id) throw new CliError(`project file ${project.file} has id ${project.id}, but business ${b.id} declares it as ${id}`, 3);
|
||||
if (project.id !== id) throw new CliError(`project file ${project.file} has id that does not match the declared project key`, 3);
|
||||
const vars = resolveInstance({ system, business: b, project, instance: first }).vars;
|
||||
if (vars["tracker.project"] !== undefined) named.push({ id, vars });
|
||||
}
|
||||
@@ -144,10 +148,29 @@ export const consoleOwners = (sso) =>
|
||||
// The `{op: 'boot'}` config for packages/bus/src/process.mjs, for one
|
||||
// business. The host holds a reader capability for the notifier and binds
|
||||
// launches later through bindLaunch, so `launches` starts empty.
|
||||
// #1873 A3: the organization's root and project agent copies, read once here. The root file is read
|
||||
// through loadRootAgents and the organization file through the organization's own writer; the
|
||||
// snapshot is plain data that the broker and the launcher re-apply to every later read of the
|
||||
// organization file, so a root file change takes effect at the next boot only. A root file that
|
||||
// refuses hides the root level (design section 6): no root copy, and the organization still boots.
|
||||
export function copiesAtBoot({ businessId, env = process.env, warn = () => {} }) {
|
||||
const dir = configDir(env), roles = rolesDir(env);
|
||||
loadBusiness(businessId, { dir, rolesDir: roles });
|
||||
const { document } = organizationWriter({ dir, rolesDir: roles }).read(businessId);
|
||||
let root = null;
|
||||
try { root = loadRootAgents(dir, { rolesDir: roles }); }
|
||||
catch (error) {
|
||||
if (!(error instanceof BusinessError)) throw error;
|
||||
warn(`root agent file refused, so ${businessId} runs no root copies until it is fixed and the bus restarts: ${error.message}`);
|
||||
}
|
||||
const snapshot = materializeCopies({ orgId: businessId, doc: document, root, rolesDir: roles });
|
||||
return { snapshot, business: withCopies(document, businessFilePath(businessId, dir), { rolesDir: roles, snapshot }) };
|
||||
}
|
||||
|
||||
export function bootConfig({ system, businessId, orgRoot = system.dataRoot, env = process.env, warn = () => {} }) {
|
||||
return business(() => {
|
||||
const vars = systemVars(system);
|
||||
const b = loadBusiness(businessId, { dir: configDir(env), rolesDir: rolesDir(env) });
|
||||
const { snapshot, business: b } = copiesAtBoot({ businessId, env, warn });
|
||||
const resolved = {};
|
||||
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: vars, business: b, instance });
|
||||
const config = {
|
||||
@@ -158,6 +181,8 @@ export function bootConfig({ system, businessId, orgRoot = system.dataRoot, env
|
||||
organization: {dir:configDir(env),rolesDir:rolesDir(env),system:vars},
|
||||
launches: [],
|
||||
readers: [b.id],
|
||||
// Present only when the organization has a copy, so a boot with none is the boot it was.
|
||||
...(Object.keys(snapshot.copies).length ? { copies: snapshot } : {}),
|
||||
};
|
||||
const tracker = trackerFor(vars, b, warn, orgRoot);
|
||||
if (tracker) {
|
||||
|
||||
@@ -143,9 +143,10 @@ import { createHash } from 'node:crypto';
|
||||
import { watchModelOutput } from './model-stall.mjs';
|
||||
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, relative } from "node:path";
|
||||
import { configDir, loadBusiness, resolveInstance, systemVars } from "../../organization/src/index.mjs";
|
||||
import { configDir, resolveInstance, systemVars } from "../../organization/src/index.mjs";
|
||||
import { organizationWriter } from "../../organization/src/organization.mjs";
|
||||
import { businessFilePath, validateBusinessDocument } from "../../organization/src/organization-config.mjs";
|
||||
import { businessFilePath } from "../../organization/src/organization-config.mjs";
|
||||
import { copyOf, copyRefusal, loadWithCopies, projectRefusal, withCopies } from "../../organization/src/agent-copies.mjs";
|
||||
import { selectModelProvider } from "../../plugins/src/model-provider-selection.mjs";
|
||||
import { THREAD_DEFINITIONS } from "../../organization/src/vocabulary.mjs";
|
||||
import { limitHit } from "../../organization/src/launch.mjs";
|
||||
@@ -244,6 +245,9 @@ export function createLauncher({
|
||||
git = {},
|
||||
stopOptions = {},
|
||||
log = (l) => process.stderr.write(`mosaic-bus: ${l}\n`),
|
||||
// #1873 A3: the root and project agent copies the bus booted with (config.mjs copiesAtBoot), or
|
||||
// null. Every read of the organization file here re-applies this snapshot, never the root file.
|
||||
copies = null,
|
||||
}) {
|
||||
if (testProxy !== null && system.proxy)
|
||||
throw Object.assign(new Error("testProxy skips P2 admission, so it is refused with system.proxy set"), { code: "test-proxy-refused" });
|
||||
@@ -257,7 +261,8 @@ export function createLauncher({
|
||||
}
|
||||
// Row 64 (#1557): where a role's skills are read from; scripts/skill.sh activate fills it.
|
||||
const skillsDir = join(system.instanceRoot ?? dataRoot, "skills-enabled");
|
||||
let b = loadBusiness(host.business, { dir: configDir(env), rolesDir: rolesDir(env) });
|
||||
const loadOrg = () => loadWithCopies(host.business, { dir: configDir(env), rolesDir: rolesDir(env), snapshot: copies?.org === host.business ? copies : null });
|
||||
let b = loadOrg();
|
||||
const vars = systemVars(system);
|
||||
|
||||
// Sessions an earlier host left in sessions.json: it died without ending
|
||||
@@ -355,10 +360,17 @@ export function createLauncher({
|
||||
try {
|
||||
const snapshot = organizationWriter({dir:configDir(env),rolesDir:rolesDir(env)}).read(host.business);
|
||||
businessRevision = snapshot.revision;
|
||||
b = validateBusinessDocument(snapshot.document, businessFilePath(host.business, configDir(env)), {rolesDir:rolesDir(env)});
|
||||
b = withCopies(snapshot.document, businessFilePath(host.business, configDir(env)), {rolesDir:rolesDir(env), snapshot: copies?.org === host.business ? copies : null});
|
||||
} catch(e){throw new Refusal("organization-refused",e.message);}
|
||||
if (!b.launch) throw new Refusal("launch-not-configured", `business ${b.id} has no launch block`);
|
||||
if (!(pm ? instance === b.launch.by : b.launch.instances.includes(instance) || (human && Object.hasOwn(b.roles, instance)))) throw new Refusal("instance-not-listed");
|
||||
// #1873 A3: an unbound or refused copy never launches. The message names the copy, its project
|
||||
// and the missing service names or the refusal, never a value.
|
||||
const copyRefused = copyRefusal(b, instance);
|
||||
if (copyRefused) throw new Refusal(copyRefused.code, copyRefused.message);
|
||||
// #1873 A3: a bound copy isn't on the chief's list. It launches when the human asks, or for a
|
||||
// thread, which only a human or a granted user makes, naming the copy.
|
||||
const boundCopy = copyOf(b, instance)?.state === "instance" && Object.hasOwn(b.roles, instance);
|
||||
if (!(pm ? instance === b.launch.by : b.launch.instances.includes(instance) || (human && Object.hasOwn(b.roles, instance)) || (thread && boundCopy))) throw new Refusal("instance-not-listed");
|
||||
const threaded = THREAD_DEFINITIONS.includes(b.roles[instance]?.definition);
|
||||
if (threaded && !thread) throw new Refusal("thread-required", `${instance} runs only for a thread`);
|
||||
if (!threaded && thread) throw new Refusal("thread-not-allowed", `${instance} isn't a thread instance`);
|
||||
@@ -539,12 +551,12 @@ export function createLauncher({
|
||||
await pickRoute(() => standIn);
|
||||
await gate(model, harness, null);
|
||||
admission(admissionGeneration);
|
||||
return { resolved, model, account: null, harness, route, routeReason, admissionGeneration, resolver };
|
||||
return { resolved, model, account: null, harness, route, routeReason, admissionGeneration, resolver, revision: businessRevision };
|
||||
}
|
||||
const sessionProxy = await pickRoute(proxyFor);
|
||||
const account = await gate(model, harness, sessionProxy);
|
||||
admission(admissionGeneration);
|
||||
return { resolved, model, account, harness, route, routeReason, admissionGeneration, sessionProxy, resolver };
|
||||
return { resolved, model, account, harness, route, routeReason, admissionGeneration, sessionProxy, resolver, revision: businessRevision };
|
||||
}
|
||||
|
||||
// Reread host state on every new-run path, including opens/clears queued before archive.
|
||||
@@ -554,6 +566,8 @@ export function createLauncher({
|
||||
const row = rows.find(r => r.thread === thread && r.session === session);
|
||||
if (!row) throw new Refusal("session-not-found");
|
||||
if (row.archived) throw new Refusal("project-archived");
|
||||
// #1873 A3: the thread's project no longer takes its instance: the union refuses, or excludes it.
|
||||
if (row.projectRefused) throw new Refusal(row.projectRefused.code, row.projectRefused.reason);
|
||||
}
|
||||
|
||||
// Bundle, spawn, bind, register, hand over the capability.
|
||||
@@ -616,13 +630,24 @@ export function createLauncher({
|
||||
// (a mapping published before the seed and records finished) is left out and the launch goes on without
|
||||
// it. Ordinary mappings also recheck archive state through the same host admission.
|
||||
const codeProjects = {};
|
||||
// #1873 A3: an instance gets only the code projects it works in: a project agent its own one, and
|
||||
// no instance a project that excludes it or whose union refuses. A project agent refuses when its
|
||||
// own project isn't admitted. A checkout an earlier launch left in the workspace for a project
|
||||
// the instance may not work in now is hidden from the session, never deleted (sandbox.mjs).
|
||||
const own = copyOf(b, instance)?.project ?? null;
|
||||
const declared = new Set();
|
||||
for (const [project, entry] of Object.entries(b.projects)) {
|
||||
if (entry.kind === 'library') continue;
|
||||
declared.add(project);
|
||||
if (own !== null && project !== own) continue;
|
||||
const unavailable = projectRefusal(b, instance, project);
|
||||
if (unavailable) { record({ event: "checkout-skipped", instance, project, reason: unavailable.code }); continue; }
|
||||
let answer = null;
|
||||
try { answer = await host.op({ op: 'request.admission', business: b.id, project }); } catch (e) { answer = { ready: false, code: e.code ?? 'admission-unavailable' }; }
|
||||
if (answer?.ready !== true) { record({ event: "checkout-skipped", instance, project, reason: answer?.code ?? 'project-pending' }); continue; }
|
||||
codeProjects[project] = entry;
|
||||
}
|
||||
if (own !== null && !Object.hasOwn(codeProjects, own)) throw new Refusal("project-refused", `${instance} works only in project ${own}, which isn't ready`);
|
||||
await threadAdmission(thread, session);
|
||||
let checkouts;
|
||||
try {
|
||||
@@ -646,9 +671,30 @@ export function createLauncher({
|
||||
catch (error) { throw new Refusal(error.code ?? 'proxy-down', error.code ?? 'proxy-down'); }
|
||||
if (!admittedProxy || typeof admittedProxy.key !== 'string' || !admittedProxy.key || admittedProxy.baseUrl !== `http://127.0.0.1:${system.proxy.port}`) throw new Refusal('proxy-down', 'proxy-down');
|
||||
}
|
||||
let harness, route, routeReason, sessionProxy, resolver;
|
||||
({resolved,model,account,harness,route,routeReason,sessionProxy,resolver}=await check(instance,{pm:instance===b.launch.by,thread,session,human,proxyContext:admittedProxy,proxyReady:true}));
|
||||
let harness, route, routeReason, sessionProxy, resolver, revision;
|
||||
({resolved,model,account,harness,route,routeReason,sessionProxy,resolver,revision}=await check(instance,{pm:instance===b.launch.by,thread,session,human,proxyContext:admittedProxy,proxyReady:true}));
|
||||
admission(admissionGeneration);
|
||||
// #1873 A3: check() read the organization again. A project that excluded or refused this
|
||||
// instance while its checkouts were prepared refuses the launch; the next one leaves it out.
|
||||
for (const project of Object.keys(codeProjects)) {
|
||||
const unavailable = projectRefusal(b, instance, project);
|
||||
if (unavailable) throw new Refusal(unavailable.code, unavailable.message);
|
||||
}
|
||||
// #1873 A3: every declared code project this launch leaves out, in the document it started from
|
||||
// or the one check() read, is hidden if an earlier launch left its checkout in the workspace,
|
||||
// whatever kept it out (not admitted, excluded, refused, another project's agent). Only a real
|
||||
// directory can be covered: a tmpfs mounted at a symlink would land on its target, so anything
|
||||
// else at that path refuses the launch, and nothing on the host is changed or removed.
|
||||
for (const [project, entry] of Object.entries(b.projects)) if (entry.kind !== 'library') declared.add(project);
|
||||
const hidden = [];
|
||||
for (const project of declared) {
|
||||
if (Object.hasOwn(codeProjects, project)) continue;
|
||||
const path = join(workspace, project);
|
||||
let stat;
|
||||
try { stat = lstatSync(path); } catch (e) { if (e.code === "ENOENT") continue; throw new Refusal("checkout-unsafe", `${instance}'s workspace path for project ${project} can't be read`); }
|
||||
if (!stat.isDirectory()) throw new Refusal("checkout-unsafe", `${instance}'s workspace path for project ${project} isn't a directory, so it can't be hidden; a person removes it before ${instance} launches again`);
|
||||
hidden.push(path);
|
||||
}
|
||||
const { run, runDir } = newRun(dataRoot, b.id);
|
||||
let modelKeyId;
|
||||
if (admittedProxy) {
|
||||
@@ -744,6 +790,7 @@ export function createLauncher({
|
||||
gitSocket: git,
|
||||
threadStore: store,
|
||||
readOnly,
|
||||
hidden,
|
||||
};
|
||||
admission(admissionGeneration);
|
||||
await threadAdmission(thread, session);
|
||||
@@ -754,7 +801,9 @@ export function createLauncher({
|
||||
}
|
||||
// One synchronous broker snapshot covers the thread and the entire manifest after every
|
||||
// asynchronous preparation read. Admission is the launch boundary; bindLaunch remains a fence.
|
||||
const finalAdmission = await host.op({ op: "request.admission", business: b.id, projects: Object.keys(codeProjects), thread, session });
|
||||
// #1873 A3: `revision` is the organization file check() resolved the bundle from; a file that
|
||||
// changed since (a narrow, a define's vars, an exclude) refuses, and the next launch resolves anew.
|
||||
const finalAdmission = await host.op({ op: "request.admission", business: b.id, projects: Object.keys(codeProjects), instance, revision, thread, session });
|
||||
if (finalAdmission?.ready !== true) throw new Refusal(finalAdmission?.code ?? "project-pending");
|
||||
const managed = registerRuntime(dataRoot, { kind: 'session', business: b.id, run });
|
||||
let child;
|
||||
@@ -889,6 +938,8 @@ export function createLauncher({
|
||||
for (const i of pending) {
|
||||
const session = registry.running(b.id, i.target, null);
|
||||
if (!session || !i.project || stopping.has(session.run)) continue;
|
||||
// #1873 A3: a manifest never gains a project its instance doesn't work in.
|
||||
if (projectRefusal(b, i.target, i.project)) continue;
|
||||
let projects = [];
|
||||
try { projects = JSON.parse(readFileSync(join(session.runDir, 'bundle/manifest.json'), 'utf8')).projects ?? []; } catch {}
|
||||
if (!projects.includes(i.project)) {
|
||||
@@ -1348,7 +1399,7 @@ export function createLauncher({
|
||||
if (typeof instance === 'string') await host.op({ op: 'request.launchResult', record: { business: b.id, instance, code: out.ok ? null : out.error, run: out.ok ? out.result.run : null } });
|
||||
return out;
|
||||
};
|
||||
try { b = loadBusiness(host.business, { dir: configDir(env), rolesDir: rolesDir(env) }); }
|
||||
try { b = loadOrg(); }
|
||||
catch { return reply({ ok: false, error: 'organization-refused' }); }
|
||||
// Product enums and clones are frozen in a session's manifest. A legitimate chief
|
||||
// launch may request a drain, never interrupt the turn or start a privileged worker.
|
||||
|
||||
@@ -353,7 +353,7 @@ test("T2 a bad user add refuses before any write", async (t) => {
|
||||
}
|
||||
const checked = [
|
||||
[USER_ADD("bob", { subject: "github:bob-sub" }), /provider must be a configured provider/],
|
||||
[USER_ADD("alice", { subject: "authentik:alice-2" }), /duplicate user id: alice/],
|
||||
[USER_ADD("alice", { subject: "authentik:alice-2" }), /duplicate user id/],
|
||||
[USER_ADD("tester"), /a bus principal/],
|
||||
[USER_ADD("jason"), /a business's human/],
|
||||
[USER_ADD("bob", { subject: "authentik:jason-sub" }), /owner sign-in subject/],
|
||||
|
||||
@@ -0,0 +1,595 @@
|
||||
import { requestHostOps } from './request-host-ops.mjs';
|
||||
// #1873 Phase A slice A3: root and project agent copies through the real bus and launcher. The
|
||||
// cli's boot step reads the root file once and hands the snapshot to the broker and the launcher;
|
||||
// a bound copy launches as an ordinary instance of the organization, an unbound or refused one
|
||||
// refuses at the verb, and a project agent gets only its own project. Real runners in the bwrap
|
||||
// sandbox on the fake adapter, with the in-process host lifecycle.test.mjs uses.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { businessDoc, launchDoc, tokenFile, writeJson } from "../../organization/tests/helpers.mjs";
|
||||
import { BusError } from "../../bus/src/broker.mjs";
|
||||
import { Client } from "../../bus/src/client.mjs";
|
||||
import { startBroker } from "../../bus/src/runtime.mjs";
|
||||
import { gitEnv } from "../../seat/src/checkout.mjs";
|
||||
import { launchLogFile } from "../../seat/src/session.mjs";
|
||||
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
import { startTimeOf } from "../src/host.mjs";
|
||||
import { createLauncher } from "../src/launcher.mjs";
|
||||
import { fakeHarnesses, fixture, tmp, withoutHostCredentials } from "./helpers.mjs";
|
||||
|
||||
const FAKE = fileURLToPath(new URL("../../harness/tests/fixtures/fake-adapter.mjs", import.meta.url));
|
||||
const skip = sandboxProblem() ?? false;
|
||||
const SECRET = "sk-test-NOT-A-SECRET-1234";
|
||||
const VARS = { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" };
|
||||
|
||||
async function until(fn, ms = 15000) {
|
||||
const end = Date.now() + ms;
|
||||
while (Date.now() < end) {
|
||||
const v = await fn();
|
||||
if (v) return v;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
throw new Error("timed out waiting");
|
||||
}
|
||||
|
||||
function gitRepo(dir) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const git = (...args) => {
|
||||
const r = spawnSync("git", ["-c", "maintenance.auto=false", "-c", "gc.auto=0", "-c", "user.name=test", "-c", "[email protected]", "-C", dir, ...args], { env: gitEnv(), encoding: "utf8" });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
};
|
||||
git("init", "-q", "-b", "main");
|
||||
writeFileSync(join(dir, "file.txt"), "one\n");
|
||||
git("add", "file.txt");
|
||||
git("commit", "-q", "-m", "one");
|
||||
}
|
||||
|
||||
// acme: two code projects, stack and web. stack defines the project agent checker. The root file
|
||||
// holds gate (merge-gate, no credential: bound), researcher (research, needs gitea: unbound in every
|
||||
// organization) and sec (security-reviewer, excluded from acme). The coder's gitea token file is
|
||||
// named for, and holds, the test secret, so it is bound elsewhere in the organization.
|
||||
function prepare(t, edit = (doc) => doc) {
|
||||
const root = tmp(t, "mosaic-copy-");
|
||||
const f = fixture(root, "acme", (doc) => {
|
||||
doc.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
|
||||
doc.roles.coder.vars.model = "claude-sonnet-5-5";
|
||||
const secret = tokenFile(join(root, "secrets"), SECRET);
|
||||
writeFileSync(secret, SECRET);
|
||||
doc.roles.coder.credentials.gitea = { ...doc.roles.coder.credentials.gitea, file: secret };
|
||||
doc.launch = launchDoc("chief", ["coder"], { opus: 1, sonnet: 4 });
|
||||
doc.projects.stack.agents = { checker: { definition: "merge-gate", vars: VARS } };
|
||||
doc.projects.web = { root: join(root, "web") };
|
||||
return edit(doc);
|
||||
});
|
||||
withoutHostCredentials(f.env, { piKey: true });
|
||||
gitRepo(f.doc.projects.stack.root);
|
||||
gitRepo(f.doc.projects.web.root);
|
||||
writeJson(join(root, "config", "agents.json"), { agentsVersion: 1, agents: {
|
||||
gate: { definition: "merge-gate", vars: VARS },
|
||||
researcher: { definition: "research", vars: VARS },
|
||||
sec: { definition: "security-reviewer", vars: VARS, excludeOrgs: ["acme"] },
|
||||
} }, 0o600);
|
||||
const adapter = join(root, "adapter.sh");
|
||||
writeFileSync(adapter, `exec '${process.execPath}' '${FAKE}'\n`, { mode: 0o644 });
|
||||
return { root, f, adapter };
|
||||
}
|
||||
|
||||
async function start(t, { f, adapter }, { businessId = "acme", orgRoot } = {}) {
|
||||
const system = loadSystem({ env: f.env });
|
||||
const { trackers, ...boot } = bootConfig({ system, businessId, env: f.env, ...(orgRoot ? { orgRoot } : {}) });
|
||||
const runtime = await startBroker(boot);
|
||||
const dataRoot = boot.dataRoot;
|
||||
mkdirSync(join(dataRoot, "bus-host"), { recursive: true, mode: 0o700 });
|
||||
const ops = {
|
||||
...requestHostOps(runtime),
|
||||
identity: (m) => runtime.identity(m.cap),
|
||||
authorizeLaunch: (m) => runtime.authorizeLaunch(m.cap, m.instance),
|
||||
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
|
||||
endLaunch: (m) => runtime.endLaunch(m.record),
|
||||
credentialStatus: (m) => runtime.credentialStatus(m.business, m.role),
|
||||
threadsState: (m) => runtime.threadsState(m.business),
|
||||
threadIdle: (m) => runtime.threadIdle(m.record),
|
||||
mayLaunch: (m) => runtime.mayLaunch(m),
|
||||
landHeld: (m) => runtime.landHeld(m.record),
|
||||
landHolders: (m) => runtime.landHolders(m.business),
|
||||
landRelease: (m) => runtime.landRelease(m.record),
|
||||
lifecycleRequests: (m) => runtime.lifecycleRequests(m.business),
|
||||
lifecycleTake: (m) => runtime.lifecycleTake(m.record),
|
||||
lifecycleDelete: (m) => runtime.lifecycleDelete(m.record),
|
||||
lifecycleDone: (m) => runtime.lifecycleDone(m.record),
|
||||
};
|
||||
const refusing = (what, fn) => async (m) => {
|
||||
try {
|
||||
return fn(m);
|
||||
} catch (e) {
|
||||
const code = e instanceof BusError ? e.code : `${what}-refused`;
|
||||
throw Object.assign(new Error(`${what} refused: ${code}`), { code });
|
||||
}
|
||||
};
|
||||
const hooks = [];
|
||||
let closing = null;
|
||||
const host = {
|
||||
business: businessId,
|
||||
path: runtime.path,
|
||||
dataRoot,
|
||||
op: refusing("launch-op", (m) => {
|
||||
if (!Object.hasOwn(ops, m.op)) throw new BusError("invalid-host-request");
|
||||
return ops[m.op](m);
|
||||
}),
|
||||
bindLaunch: refusing("launch bind", (r) => runtime.bindLaunch(r)),
|
||||
beforeClose: (fn) => void hooks.push(fn),
|
||||
};
|
||||
const logs = [];
|
||||
const launcher = createLauncher({
|
||||
host,
|
||||
system: orgRoot ? { ...system, dataRoot } : system,
|
||||
env: f.env,
|
||||
makeLoader: await fakeHarnesses(t, { pi: adapter, "claude-code": adapter }),
|
||||
sessionDefaults: { pollInterval: 100 },
|
||||
harnessVersion: "0.85.1",
|
||||
readOnly: [FAKE],
|
||||
threadTickMs: 3600000,
|
||||
stopTimeoutMs: 1000,
|
||||
log: (l) => logs.push(l),
|
||||
copies: boot.copies ?? null,
|
||||
});
|
||||
const close = () =>
|
||||
(closing ??= (async () => {
|
||||
for (const fn of hooks) await fn(0);
|
||||
await runtime.close();
|
||||
})());
|
||||
t.after(close);
|
||||
await launcher.listen();
|
||||
const human = runtime.broker.bindHuman({ business: businessId, human: f.doc.human, via: "cli", outsideAgent: true });
|
||||
const call = (verb, args = {}) => runtime.broker.request(human, { verb, args });
|
||||
const carry = async (request) => {
|
||||
await launcher.tick();
|
||||
return call("instance.request", { request });
|
||||
};
|
||||
const running = (instance) => launcher.registry.sessions.filter((s) => s.instance === instance);
|
||||
return { boot, dataRoot, runtime, launcher, host, call, carry, running, logs, close };
|
||||
}
|
||||
|
||||
// What the bus's trail holds: every event body, as text.
|
||||
function trail(dataRoot) {
|
||||
const db = new DatabaseSync(join(dataRoot, "bus", "bus.sqlite"), { readOnly: true });
|
||||
try {
|
||||
return db.prepare("SELECT kind, body FROM events").all().map((e) => `${e.kind} ${e.body}`).join("\n");
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
const refusedAt = (s, instance) => {
|
||||
try {
|
||||
s.call("instance.launch", { instance });
|
||||
} catch (e) {
|
||||
assert.ok(e instanceof BusError, String(e));
|
||||
return e;
|
||||
}
|
||||
assert.fail(`${instance} launched`);
|
||||
};
|
||||
|
||||
test("A3: a bound root copy launches as an instance of the organization; the excluded one and the root assistant aren't there", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
assert.deepEqual(Object.keys(s.boot.copies.copies).sort(), ["checker.stack", "gate", "researcher"]);
|
||||
const done = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(done.done.refusal, null, JSON.stringify(done));
|
||||
const [run] = s.running("gate");
|
||||
assert.equal(run.run, done.done.run);
|
||||
// The grid read names it running from the bus's own claims.
|
||||
await until(() => readFileSync(join(s.dataRoot, "launches", "acme", run.run, "runner.log"), "utf8").includes("claimed by run"));
|
||||
const grid = s.call("instances");
|
||||
assert.equal(grid.copies.gate.state, "instance");
|
||||
assert.deepEqual(grid.copies.gate.running, [run.run]);
|
||||
// sec is excluded from acme: no copy, no instance, and a launch names it unknown.
|
||||
assert.equal(Object.hasOwn(grid.copies, "sec"), false);
|
||||
assert.equal(refusedAt(s, "sec").code, "unknown-role");
|
||||
});
|
||||
|
||||
test("A3: an unbound copy refuses at the verb by name and binding; no value reaches the refusal, the trail or the grid", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const e = refusedAt(s, "researcher");
|
||||
assert.equal(e.code, "credential-unbound");
|
||||
assert.match(e.reason, /^researcher has no binding in acme for gitea$/);
|
||||
const grid = JSON.stringify(s.call("instances"));
|
||||
assert.match(grid, /"researcher":\{[^}]*"state":"unbound"/);
|
||||
for (const text of [e.message, e.reason, grid, trail(s.dataRoot), s.logs.join("\n")]) {
|
||||
assert.equal(text.includes(SECRET), false);
|
||||
assert.equal(text.includes(p.root), false, "no host path");
|
||||
}
|
||||
// Nothing was asked of the launcher.
|
||||
await s.launcher.tick();
|
||||
assert.equal(s.running("researcher").length, 0);
|
||||
});
|
||||
|
||||
test("A3: a project agent launches with only its own project's mapping; a project whose union refuses refuses every agent in it", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const done = await s.carry(s.call("instance.launch", { instance: "checker.stack" }).request);
|
||||
assert.equal(done.done.refusal, null, JSON.stringify(done));
|
||||
const [run] = s.running("checker.stack");
|
||||
const manifest = JSON.parse(readFileSync(join(run.runDir, "bundle", "manifest.json"), "utf8"));
|
||||
assert.deepEqual(manifest.projects, ["stack"]);
|
||||
// An org instance of the same role takes both code projects.
|
||||
const coder = await s.carry(s.call("instance.launch", { instance: "coder" }).request);
|
||||
assert.equal(coder.done.refusal, null, JSON.stringify(coder));
|
||||
const [c] = s.running("coder");
|
||||
assert.deepEqual(JSON.parse(readFileSync(join(c.runDir, "bundle", "manifest.json"), "utf8")).projects.sort(), ["stack", "web"]);
|
||||
|
||||
// web excludes an agent nothing defines: its union refuses, so its agents refuse, naming web.
|
||||
const q = prepare(t, (doc) => {
|
||||
doc.projects.web.agents = { checker: { definition: "merge-gate", vars: VARS }, lint: { definition: "security-reviewer", vars: VARS }, ghost: { exclude: true } };
|
||||
return doc;
|
||||
});
|
||||
const r = await start(t, q);
|
||||
for (const name of ["checker.web", "lint.web"]) {
|
||||
const e = refusedAt(r, name);
|
||||
assert.equal(e.code, "project-refused");
|
||||
assert.match(e.reason, new RegExp(`^${name.replace(".", "\\.")} can't launch in acme: project web refuses its agents: .*ghost`));
|
||||
}
|
||||
// The other project's agent is untouched.
|
||||
assert.equal((await r.carry(r.call("instance.launch", { instance: "checker.stack" }).request)).done.refusal, null);
|
||||
// An inherited root copy and an organization instance still launch, without web's mount.
|
||||
for (const instance of ["gate", "coder"]) {
|
||||
const out = await r.carry(r.call("instance.launch", { instance }).request);
|
||||
assert.equal(out.done.refusal, null, JSON.stringify(out));
|
||||
const [inherited] = r.running(instance);
|
||||
assert.deepEqual(projectsOf(inherited), ["stack"], instance);
|
||||
}
|
||||
});
|
||||
|
||||
const projectsOf = (run) => JSON.parse(readFileSync(join(run.runDir, "bundle", "manifest.json"), "utf8")).projects.sort();
|
||||
const bundleOf = (run, file) => JSON.parse(readFileSync(join(run.runDir, "bundle", file), "utf8"));
|
||||
|
||||
test("A3: a project's exclude takes its mount from an inherited agent, and its narrow reaches the agent's real bundle", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t, (doc) => {
|
||||
doc.projects.stack.agents.gate = { vars: { "limits.tools": ["read"] } };
|
||||
doc.projects.web.agents = { gate: { exclude: true } };
|
||||
return doc;
|
||||
});
|
||||
const s = await start(t, p);
|
||||
const done = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(done.done.refusal, null, JSON.stringify(done));
|
||||
const [run] = s.running("gate");
|
||||
assert.deepEqual(projectsOf(run), ["stack"]);
|
||||
const tools = bundleOf(run, "policy.json").tools;
|
||||
assert.ok(tools.includes("read"), JSON.stringify(tools));
|
||||
assert.equal(tools.includes("grep"), false, JSON.stringify(tools));
|
||||
// The launch record names the skipped project and the code, never a reason text.
|
||||
const skipped = readFileSync(launchLogFile(s.dataRoot, "acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l)).filter((e) => e.event === "checkout-skipped");
|
||||
assert.deepEqual(skipped.map(({ instance, project, reason }) => ({ instance, project, reason })), [{ instance: "gate", project: "web", reason: "project-refused" }]);
|
||||
});
|
||||
|
||||
// Writes the organization file the way a human edit does, outside any writer verb.
|
||||
const editOrg = (p, change) => {
|
||||
const file = join(p.root, "config", "businesses", "acme.json");
|
||||
const doc = JSON.parse(readFileSync(file, "utf8"));
|
||||
change(doc);
|
||||
writeFileSync(file, JSON.stringify(doc, null, 2) + "\n", { mode: 0o600 });
|
||||
};
|
||||
// A writer verb reloads the broker from the file. Keeping coder twice changes nothing and refuses,
|
||||
// so a repeat reload drops the keep instead.
|
||||
const reload = (s) => {
|
||||
try { return s.call("veto.set", { revision: s.call("instances").revision, op: "keep", name: "coder" }); }
|
||||
catch (e) { if (e.code !== "organization-no-change") throw e; }
|
||||
return s.call("veto.set", { revision: s.call("instances").revision, op: "unkeep", name: "coder" });
|
||||
};
|
||||
// What the run's first turn said, from inside the real sandbox, about the web checkout.
|
||||
async function webSeen(s, run) {
|
||||
await until(() => existsSync(join(run.runDir, "runner.log")) && readFileSync(join(run.runDir, "runner.log"), "utf8").includes("claimed by run"));
|
||||
s.call("message.send", { to: run.instance, class: "REQUEST", body: "fixture" });
|
||||
const said = join(run.runDir, "turns", "0001.stderr");
|
||||
await until(() => existsSync(said) && /WEB-(READABLE|ABSENT)/.test(readFileSync(said, "utf8")));
|
||||
return readFileSync(said, "utf8").match(/WEB-(READABLE|ABSENT)/)[1];
|
||||
}
|
||||
|
||||
test("A3: a checkout an earlier launch made is out of sight in the sandbox once its project excludes the agent, and stays on the host", { skip, timeout: 90000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
// The adapter runs inside the sandbox and says only whether the web checkout's file is readable.
|
||||
writeFileSync(p.adapter, `if test -r "$MOSAIC_WORKSPACE/web/file.txt"; then echo WEB-READABLE >&2; else echo WEB-ABSENT >&2; fi\nexec '${process.execPath}' '${FAKE}'\n`);
|
||||
const s = await start(t, p);
|
||||
let out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(out.done.refusal, null, JSON.stringify(out));
|
||||
let [run] = s.running("gate");
|
||||
assert.deepEqual(projectsOf(run), ["stack", "web"]);
|
||||
assert.equal(await webSeen(s, run), "READABLE");
|
||||
await s.carry(s.call("instance.end", { instance: "gate" }).request);
|
||||
await until(() => s.running("gate").length === 0);
|
||||
for (const exclude of [{ gate: { exclude: true } }, { ghost: { exclude: true } }]) {
|
||||
// The project excludes gate, then refuses its whole union.
|
||||
editOrg(p, (d) => { d.projects.web.agents = exclude; });
|
||||
reload(s);
|
||||
out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(out.done.refusal, null, JSON.stringify(out));
|
||||
[run] = s.running("gate");
|
||||
assert.deepEqual(projectsOf(run), ["stack"]);
|
||||
assert.equal(await webSeen(s, run), "ABSENT");
|
||||
await s.carry(s.call("instance.end", { instance: "gate" }).request);
|
||||
await until(() => s.running("gate").length === 0);
|
||||
}
|
||||
// The earlier work is still on the host, untouched.
|
||||
assert.equal(readFileSync(join(s.dataRoot, "workspaces", "acme", "gate", "web", "file.txt"), "utf8"), "one\n");
|
||||
});
|
||||
|
||||
test("A3: a project that excludes the agent while its launch is prepared refuses the launch; the bus's last admission asks too", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const op = s.host.op;
|
||||
let changed = false;
|
||||
s.host.op = async (m) => {
|
||||
const out = await op(m);
|
||||
if (!changed && m.op === "request.admission" && m.project === "web") {
|
||||
changed = true;
|
||||
editOrg(p, (d) => { d.projects.web.agents = { gate: { exclude: true } }; });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(changed, true);
|
||||
assert.equal(out.done.refusal, "project-refused", JSON.stringify(out));
|
||||
assert.equal(s.running("gate").length, 0);
|
||||
// Once the bus has the change, its launch-boundary admission refuses the instance there too.
|
||||
reload(s);
|
||||
const revision = s.call("instances").revision;
|
||||
assert.deepEqual(s.runtime.admission({ business: "acme", projects: ["stack", "web"], instance: "gate", revision }).code, "project-refused");
|
||||
assert.equal(s.runtime.admission({ business: "acme", projects: ["stack"], instance: "gate", revision }).ready, true);
|
||||
assert.equal(s.runtime.admission({ business: "acme", projects: ["stack", "web"] }).ready, true);
|
||||
// The launch boundary takes an instance only with the revision its bundle came from.
|
||||
assert.equal(s.runtime.admission({ business: "acme", projects: ["stack"], instance: "gate" }).code, "project-unknown");
|
||||
assert.equal(s.runtime.admission({ business: "acme", projects: ["stack"], instance: "gate", revision: "0".repeat(64) }).code, "organization-changed");
|
||||
});
|
||||
|
||||
// Launches gate with both projects, reads web from inside the sandbox, and ends the run, so the
|
||||
// workspace keeps a web checkout an earlier launch made.
|
||||
async function launchBoth(s) {
|
||||
const out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(out.done.refusal, null, JSON.stringify(out));
|
||||
const [run] = s.running("gate");
|
||||
assert.deepEqual(projectsOf(run), ["stack", "web"]);
|
||||
assert.equal(await webSeen(s, run), "READABLE");
|
||||
await s.carry(s.call("instance.end", { instance: "gate" }).request);
|
||||
await until(() => s.running("gate").length === 0);
|
||||
}
|
||||
const readsWeb = (p) => writeFileSync(p.adapter, `if test -r "$MOSAIC_WORKSPACE/web/file.txt"; then echo WEB-READABLE >&2; else echo WEB-ABSENT >&2; fi\nexec '${process.execPath}' '${FAKE}'\n`);
|
||||
|
||||
test("A3: a checkout of a project the bus doesn't admit now is out of sight too, not only an excluded one", { skip, timeout: 90000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
readsWeb(p);
|
||||
const s = await start(t, p);
|
||||
await launchBoth(s);
|
||||
// web is declared and gate is in its union, but the bus doesn't admit the project for this launch.
|
||||
const op = s.host.op;
|
||||
s.host.op = async (m) => (m.op === "request.admission" && m.project === "web" ? { ready: false, revision: null, code: "project-pending" } : op(m));
|
||||
const out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(out.done.refusal, null, JSON.stringify(out));
|
||||
const [run] = s.running("gate");
|
||||
assert.deepEqual(projectsOf(run), ["stack"]);
|
||||
assert.equal(await webSeen(s, run), "ABSENT");
|
||||
assert.equal(readFileSync(join(s.dataRoot, "workspaces", "acme", "gate", "web", "file.txt"), "utf8"), "one\n");
|
||||
});
|
||||
|
||||
test("A3: a symlink or a file where a hidden checkout would be refuses the launch, and nothing on the host is touched", { skip, timeout: 90000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
readsWeb(p);
|
||||
const s = await start(t, p);
|
||||
await launchBoth(s);
|
||||
const ws = join(s.dataRoot, "workspaces", "acme", "gate");
|
||||
// The run's own workspace moves the checkout aside and leaves a link at web, then web excludes gate.
|
||||
renameSync(join(ws, "web"), join(ws, "kept"));
|
||||
symlinkSync(join(ws, "kept"), join(ws, "web"));
|
||||
editOrg(p, (d) => { d.projects.web.agents = { gate: { exclude: true } }; });
|
||||
reload(s);
|
||||
let out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(out.done.refusal, "checkout-unsafe", JSON.stringify(out));
|
||||
assert.equal(s.running("gate").length, 0);
|
||||
assert.equal(lstatSync(join(ws, "web")).isSymbolicLink(), true);
|
||||
assert.equal(readFileSync(join(ws, "kept", "file.txt"), "utf8"), "one\n");
|
||||
// A plain file there refuses the same way.
|
||||
renameSync(join(ws, "web"), join(ws, "link"));
|
||||
writeFileSync(join(ws, "web"), "not a checkout\n");
|
||||
out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(out.done.refusal, "checkout-unsafe", JSON.stringify(out));
|
||||
assert.equal(readFileSync(join(ws, "web"), "utf8"), "not a checkout\n");
|
||||
});
|
||||
|
||||
test("A3: a narrow that lands after the launcher's recheck and before the bus's last admission refuses the launch; the next one takes it", { skip, timeout: 90000 }, async (t) => {
|
||||
for (const [instance, narrow, after] of [
|
||||
// An inherited agent's narrow in its project.
|
||||
["gate", (d) => { d.projects.stack.agents.gate = { vars: { "limits.tools": ["read"] } }; }, "launches"],
|
||||
// A project agent's define: its vars change after boot.
|
||||
["checker.stack", (d) => { d.projects.stack.agents.checker.vars["limits.tools"] = ["read"]; }, "project-refused"],
|
||||
]) {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const op = s.host.op;
|
||||
let landed = false;
|
||||
// The narrow lands after every launcher read, just before the bus's last admission answers.
|
||||
s.host.op = async (m) => {
|
||||
if (!landed && m.op === "request.admission" && m.projects !== undefined) { landed = true; editOrg(p, narrow); reload(s); }
|
||||
return op(m);
|
||||
};
|
||||
let out = await s.carry(s.call("instance.launch", { instance }).request);
|
||||
assert.equal(landed, true, instance);
|
||||
assert.equal(out.done.refusal, "organization-changed", `${instance} ${JSON.stringify(out)}`);
|
||||
assert.equal(s.running(instance).length, 0, instance);
|
||||
// The next launch resolves from the narrowed file.
|
||||
if (after === "launches") {
|
||||
out = await s.carry(s.call("instance.launch", { instance }).request);
|
||||
assert.equal(out.done.refusal, null, JSON.stringify(out));
|
||||
const tools = bundleOf(s.running(instance)[0], "policy.json").tools;
|
||||
assert.ok(tools.includes("read"), JSON.stringify(tools));
|
||||
assert.equal(tools.includes("grep"), false, JSON.stringify(tools));
|
||||
} else {
|
||||
assert.equal(refusedAt(s, instance).code, after, instance);
|
||||
}
|
||||
await s.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("A3: a project define whose vars change after boot refuses until the next start rather than launch with the old ones", { skip, timeout: 90000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
let s = await start(t, p);
|
||||
editOrg(p, (d) => { d.projects.stack.agents.checker.vars["limits.tools"] = ["read"]; });
|
||||
reload(s);
|
||||
const copy = s.call("instances").copies["checker.stack"];
|
||||
assert.equal(copy.state, "refused");
|
||||
assert.match(copy.reason, /^project stack changed checker's vars since the bus started/);
|
||||
assert.equal(refusedAt(s, "checker.stack").code, "project-refused");
|
||||
await s.close();
|
||||
s = await start(t, p);
|
||||
const out = await s.carry(s.call("instance.launch", { instance: "checker.stack" }).request);
|
||||
assert.equal(out.done.refusal, null, JSON.stringify(out));
|
||||
const tools = bundleOf(s.running("checker.stack")[0], "policy.json").tools;
|
||||
assert.ok(tools.includes("read"), JSON.stringify(tools));
|
||||
assert.equal(tools.includes("grep"), false, JSON.stringify(tools));
|
||||
});
|
||||
|
||||
test("A3: a collision a project brings in after boot refuses the copy it collides with, naming both claimants", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const rootFile = join(p.root, "config", "agents.json");
|
||||
const root = JSON.parse(readFileSync(rootFile, "utf8"));
|
||||
root.agents["new.stack"] = { definition: "security-reviewer", vars: VARS };
|
||||
writeFileSync(rootFile, JSON.stringify(root));
|
||||
const s = await start(t, p);
|
||||
assert.equal(s.call("instances").copies["new.stack"].state, "instance");
|
||||
editOrg(p, (d) => { d.projects.stack.agents.new = { definition: "merge-gate", vars: VARS }; });
|
||||
reload(s);
|
||||
const copy = s.call("instances").copies["new.stack"];
|
||||
assert.equal(copy.state, "refused");
|
||||
assert.deepEqual(copy.claims, [{ name: "new.stack", project: null }, { name: "new", project: "stack" }]);
|
||||
assert.equal(copy.reason, "the instance name new.stack is claimed by root agent new.stack and project stack's new");
|
||||
assert.equal(refusedAt(s, "new.stack").code, "copy-refused");
|
||||
// Taking the project's define back out frees the root copy again.
|
||||
editOrg(p, (d) => { delete d.projects.stack.agents.new; });
|
||||
reload(s);
|
||||
assert.equal(s.call("instances").copies["new.stack"].state, "instance");
|
||||
});
|
||||
|
||||
test("A3: an organization instance written over a root copy after boot launches as itself, with its own role", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
assert.equal(s.call("instances").copies.gate.state, "instance");
|
||||
const file = join(p.root, "config", "businesses", "acme.json");
|
||||
const doc = JSON.parse(readFileSync(file, "utf8"));
|
||||
doc.roles.gate = { definition: "security-reviewer", vars: VARS };
|
||||
writeFileSync(file, JSON.stringify(doc, null, 2) + "\n", { mode: 0o600 });
|
||||
// A writer verb reloads the broker from the file, as any human edit would.
|
||||
const { revision } = s.call("instances");
|
||||
s.call("veto.set", { revision, op: "keep", name: "coder" });
|
||||
assert.equal(Object.hasOwn(s.call("instances").copies ?? {}, "gate"), false);
|
||||
const done = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(done.done.refusal, null, JSON.stringify(done));
|
||||
const [run] = s.running("gate");
|
||||
assert.equal(bundleOf(run, "manifest.json").definition, "security-reviewer");
|
||||
});
|
||||
|
||||
test("A3: two organizations each launch their own gate copy; runs, sessions and trails stay under each one's data root", { skip, timeout: 90000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const beta = businessDoc(p.root, "beta");
|
||||
for (const r of Object.values(beta.roles)) r.vars = { ...VARS, ...r.vars, model: r.vars?.model ?? "claude-sonnet-5-5" };
|
||||
beta.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
|
||||
beta.launch = launchDoc("chief", ["coder"], { opus: 1, sonnet: 4 });
|
||||
beta.plugins = { ...beta.plugins, harness: ["pi", "claude-code"] };
|
||||
writeJson(join(p.root, "config", "businesses", "beta.json"), beta);
|
||||
const a = await start(t, p);
|
||||
mkdirSync(join(p.root, "ob"), { mode: 0o700 });
|
||||
const b = await start(t, p, { businessId: "beta", orgRoot: join(p.root, "ob") });
|
||||
const runs = {};
|
||||
for (const [id, s] of [["acme", a], ["beta", b]]) {
|
||||
const done = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(done.done.refusal, null, JSON.stringify(done));
|
||||
const [run] = s.running("gate");
|
||||
runs[id] = run;
|
||||
assert.ok(run.runDir.startsWith(join(s.dataRoot, "launches", id) + "/"), run.runDir);
|
||||
await until(() => existsSync(join(run.runDir, "session")));
|
||||
await until(() => readFileSync(join(run.runDir, "runner.log"), "utf8").includes("claimed by run"));
|
||||
assert.deepEqual(s.call("instances").copies.gate.running, [run.run]);
|
||||
}
|
||||
assert.notEqual(runs.acme.run, runs.beta.run);
|
||||
// Neither bus, trail or data root holds the other's run.
|
||||
for (const [mine, other, s] of [["acme", "beta", a], ["beta", "acme", b]]) {
|
||||
assert.equal(trail(s.dataRoot).includes(runs[other].run), false, mine);
|
||||
assert.equal(existsSync(join(s.dataRoot, "launches", other)), false, mine);
|
||||
assert.equal(readdirSync(s.dataRoot, { recursive: true }).join("\n").includes(runs[other].run), false, mine);
|
||||
}
|
||||
assert.equal(b.running("gate").some((r) => r.run === runs.acme.run), false);
|
||||
});
|
||||
|
||||
test("A3: a root file change while the bus runs reaches no relaunch or reload; the next boot takes it", { skip, timeout: 90000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const rootFile = join(p.root, "config", "agents.json");
|
||||
writeJson(rootFile, { agentsVersion: 1, agents: { fresh: { definition: "merge-gate", vars: VARS } } }, 0o600);
|
||||
// A launch, a relaunch and a reload all keep the boot's copy set.
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const done = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(done.done.refusal, null, JSON.stringify(done));
|
||||
await until(() => s.running("gate").length === 1 && readFileSync(join(s.running("gate")[0].runDir, "runner.log"), "utf8").includes("claimed by run"));
|
||||
const { revision } = s.call("instances");
|
||||
s.call("veto.set", { revision, op: i ? "unkeep" : "keep", name: "coder" });
|
||||
assert.deepEqual(Object.keys(s.call("instances").copies).sort(), ["checker.stack", "gate", "researcher"]);
|
||||
assert.equal(refusedAt(s, "fresh").code, "unknown-role");
|
||||
await s.carry(s.call("instance.end", { instance: "gate" }).request);
|
||||
await until(() => s.running("gate").length === 0);
|
||||
}
|
||||
await s.close();
|
||||
// A restart reads the file again.
|
||||
const next = await start(t, p);
|
||||
assert.deepEqual(Object.keys(next.boot.copies.copies).sort(), ["checker.stack", "fresh"]);
|
||||
assert.equal(refusedAt(next, "gate").code, "unknown-role");
|
||||
assert.equal((await next.carry(next.call("instance.launch", { instance: "fresh" }).request)).done.refusal, null);
|
||||
});
|
||||
|
||||
test("A3: the chief can't launch a copy; only the human or a thread names one", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const chief = await s.host.bindLaunch({ business: "acme", role: "chief", run: "chief-test", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) });
|
||||
await new Client({ path: s.runtime.path, cap: chief.cap }).call("role.claim");
|
||||
for (const instance of ["gate", "checker.stack"]) {
|
||||
const out = await s.launcher.fromSession(chief.cap, instance);
|
||||
assert.deepEqual(out, { ok: false, error: "instance-not-listed" }, instance);
|
||||
}
|
||||
const unbound = await s.launcher.fromSession(chief.cap, "researcher");
|
||||
assert.deepEqual(unbound, { ok: false, error: "credential-unbound" });
|
||||
assert.equal(s.launcher.registry.sessions.length, 0);
|
||||
});
|
||||
|
||||
test("A3: a second organization boots its own copies from the same root file and shares no record, request or session with the first", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const beta = businessDoc(p.root, "beta");
|
||||
for (const r of Object.values(beta.roles)) r.vars = { ...VARS, ...r.vars, model: r.vars?.model ?? "claude-sonnet-5-5" };
|
||||
beta.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
|
||||
beta.launch = launchDoc("chief", ["coder"], { opus: 1, sonnet: 4 });
|
||||
beta.plugins = { ...beta.plugins, harness: ["pi", "claude-code"] };
|
||||
writeJson(join(p.root, "config", "businesses", "beta.json"), beta);
|
||||
const a = await start(t, p);
|
||||
mkdirSync(join(p.root, "ob"), { mode: 0o700 });
|
||||
const b = await start(t, p, { businessId: "beta", orgRoot: join(p.root, "ob") });
|
||||
assert.notEqual(a.dataRoot, b.dataRoot);
|
||||
assert.equal(a.boot.copies.org, "acme");
|
||||
assert.equal(b.boot.copies.org, "beta");
|
||||
// beta isn't excluded from sec, and has no project agent of acme's.
|
||||
assert.deepEqual(Object.keys(b.boot.copies.copies).sort(), ["gate", "researcher", "sec"]);
|
||||
const { request } = a.call("instance.launch", { instance: "gate" });
|
||||
const done = await a.carry(request);
|
||||
assert.equal(done.done.refusal, null, JSON.stringify(done));
|
||||
const [run] = a.running("gate");
|
||||
// beta's bus holds none of it: no request, no claim, no run, no session.
|
||||
assert.throws(() => b.call("instance.request", { request }), BusError);
|
||||
assert.deepEqual(b.call("instances").copies.gate.running, []);
|
||||
assert.equal(trail(b.dataRoot).includes(run.run), false);
|
||||
assert.equal(existsSync(join(b.dataRoot, "launches", "acme")), false);
|
||||
const sessions = (root) => existsSync(join(root, "sessions")) ? readdirSync(join(root, "sessions"), { recursive: true }).join("\n") : "";
|
||||
assert.equal(sessions(b.dataRoot).includes(run.run), false);
|
||||
assert.equal(b.running("gate").length, 0);
|
||||
});
|
||||
@@ -71,7 +71,7 @@ function world(t, { profile = "library" } = {}) {
|
||||
script(join(bin, "stack-copy"), `echo "stack-copy $*" >>'${log}'
|
||||
[ "$1" = copy ] && [ "$3" = --copies ] || exit 4
|
||||
mkdir -p "$4/$2/scripts" && : >"$4/$2/scripts/mosaic" && echo "copied: $4/$2"
|
||||
for file in scripts/case2-persona-import.mjs packages/harness/src/bundle.mjs packages/harness/src/tools.mjs packages/business/src/vocabulary.mjs packages/business/src/selector.mjs packages/organization/src/vocabulary.mjs packages/organization/src/selector.mjs packages/plugins/model-provider/cliproxy/index.mjs; do
|
||||
for file in scripts/case2-persona-import.mjs packages/harness/src/bundle.mjs packages/harness/src/tools.mjs packages/organization/src/rails.mjs packages/bus/src/refusals.mjs packages/business/src/vocabulary.mjs packages/business/src/selector.mjs packages/organization/src/vocabulary.mjs packages/organization/src/selector.mjs packages/plugins/model-provider/cliproxy/index.mjs; do
|
||||
mkdir -p "$4/$2/$(dirname "$file")" && cp '${REPO}'"$file" "$4/$2/$file" || exit 1
|
||||
done
|
||||
mkdir -p "$4/$2/node_modules" && cp -R '${REPO}node_modules/yaml' "$4/$2/node_modules/yaml"`);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
function scratch(t) { const root = mkdtempSync(join(tmpdir(), 'r4-')); t.after(() => rmSync(root, { recursive: true, force: true })); return root; }
|
||||
function safe(label, Type, props = {}, omitted = secret) { return error => {
|
||||
assert.ok(error instanceof Type, error.stack);
|
||||
for (const [key, value] of Object.entries(props)) assert.equal(error[key], value);
|
||||
assert.ok(error.message.includes(label), error.message);
|
||||
assert.doesNotMatch(error.message + error.stack, /sk-test|NOT-A-SECRET|Unexpected token/);
|
||||
assert.ok(!error.message.includes(omitted), error.message);
|
||||
assert.equal(error.cause, undefined);
|
||||
return true;
|
||||
}; }
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fixture } from './helpers.mjs';
|
||||
import { runConfigCommand } from '../../organization/src/config-command.mjs';
|
||||
const repo = join(import.meta.dirname, '../../..');
|
||||
const task = () => ({ taskVersion: 1, id: 'valid', prompt: 'hello' });
|
||||
for (const [field, edit] of [
|
||||
['task id', d => { d.id = secret; }],
|
||||
['taskVersion', d => { d.taskVersion = secret; }],
|
||||
['tool', d => { d.capabilities = { tools: [secret] }; }],
|
||||
['mission id', (d, root) => { d.mission = 'mission.json'; writeFileSync(join(root, d.mission), JSON.stringify({ missionVersion: 1, id: secret, objective: 'hello' })); }],
|
||||
['missionVersion', (d, root) => { d.mission = 'mission.json'; writeFileSync(join(root, d.mission), JSON.stringify({ missionVersion: secret, id: 'valid', objective: 'hello' })); }],
|
||||
['mission tool', (d, root) => { d.mission = 'mission.json'; writeFileSync(join(root, d.mission), JSON.stringify({ missionVersion: 1, id: 'valid', objective: 'hello', capabilities: { tools: [secret] } })); }],
|
||||
]) test(`task validation ${field} omits file value and keeps exit 2`, t => {
|
||||
const root = scratch(t), d = task(); edit(d, root); const file = join(root, 'task.json'); writeFileSync(file, JSON.stringify(d));
|
||||
const r = spawnSync(process.execPath, [join(repo, 'scripts/mosaic-task.mjs'), 'validate', file], { encoding: 'utf8', env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
|
||||
assert.equal(r.status, 2, r.stderr); assert.ok(r.stderr.includes(field), r.stderr); assert.doesNotMatch(r.stderr, /sk-test|NOT-A-SECRET/);
|
||||
});
|
||||
const configCases = [
|
||||
...['configVersion', 'environment', 'dataRoot'].map(k => [k, d => { d[k] = secret; }]),
|
||||
...['backend', 'provider', 'model', 'adapter'].map(k => [`execution.${k}`, d => { d.execution[k] = secret + '\n'; }]),
|
||||
['federation.self', d => { d.federation = { self: secret, peers: [] }; }],
|
||||
['federation.peers[0].id', d => { d.federation = { self: 'self', peers: [{ id: secret, origin: 'https://peer.example', label: 'Peer' }] }; }],
|
||||
['federation.peers[0].origin', d => { d.federation = { self: 'self', peers: [{ id: 'peer', origin: secret, label: 'Peer' }] }; }],
|
||||
];
|
||||
for (const [field, edit] of configCases) test(`config ${field} omits the configured value`, t => {
|
||||
const root = scratch(t), f = fixture(root), d = JSON.parse(readFileSync(f.config, 'utf8')); edit(d); writeFileSync(f.config, JSON.stringify(d));
|
||||
const r = spawnSync(process.execPath, [join(repo, 'scripts/mosaic-config.mjs'), 'validate'], { encoding: 'utf8', env: { ...f.env, PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
|
||||
assert.equal(r.status, 2, r.stderr); assert.ok(r.stderr.includes(field), r.stderr); assert.doesNotMatch(r.stderr, /sk-test|NOT-A-SECRET/);
|
||||
});
|
||||
test('config-command refuses system config without relaying stderr file values', t => {
|
||||
const root = scratch(t), f = fixture(root), d = JSON.parse(readFileSync(f.config, 'utf8')); d.execution.model = secret + '\n'; writeFileSync(f.config, JSON.stringify(d));
|
||||
let err = ''; const status = runConfigCommand(['validate', 'acme'], { env: f.env, stdout: { write() {} }, stderr: { write(s) { err += s; } } });
|
||||
assert.equal(status, 3); assert.match(err, /system config problem \(mosaic-config exit 2\)/); assert.ok(err.includes(f.config), err); assert.doesNotMatch(err, /sk-test|NOT-A-SECRET/); assert.match(err, /execution.model/); assert.match(err, /expected a 1-200 character name/);
|
||||
});
|
||||
test('project id mismatch omits the file value in config-command', t => {
|
||||
const root = scratch(t), f = fixture(root), file = join(f.doc.projects.stack.root, '.mosaic', 'project.json');
|
||||
mkdirSync(join(f.doc.projects.stack.root, '.mosaic'));
|
||||
writeFileSync(file, JSON.stringify({ projectVersion: 1, id: secret.toLowerCase() }));
|
||||
let err = ''; const status = runConfigCommand(['validate', 'acme'], { env: f.env, stdout: { write() {} }, stderr: { write(s) { err += s; } } });
|
||||
assert.equal(status, 2); assert.ok(err.includes(file), err); assert.match(err, /id/); assert.doesNotMatch(err, /sk-test|NOT-A-SECRET/);
|
||||
});
|
||||
for (const [field, edit] of [
|
||||
['dataRoot', d => { d.dataRoot = `/x/${secret}/../y`; }],
|
||||
['federation.peers[0].id', d => { d.federation = { self: secret.toLowerCase(), peers: [{ id: secret.toLowerCase(), origin: 'https://peer.example', label: 'Peer' }] }; }],
|
||||
]) test(`config ${field} repeated/canonical value is omitted`, t => {
|
||||
const root = scratch(t), f = fixture(root), d = JSON.parse(readFileSync(f.config, 'utf8')); edit(d); writeFileSync(f.config, JSON.stringify(d));
|
||||
const r = spawnSync(process.execPath, [join(repo, 'scripts/mosaic-config.mjs'), 'validate'], { encoding: 'utf8', env: { ...f.env, PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
|
||||
assert.equal(r.status, 2); assert.ok(r.stderr.includes(field), r.stderr); assert.doesNotMatch(r.stderr, /sk-test|NOT-A-SECRET/);
|
||||
});
|
||||
for (const mission of [false, true]) test(`duplicate ${mission ? 'mission' : 'task'} tool omits the validated value`, t => {
|
||||
const root = scratch(t), d = task();
|
||||
if (mission) { d.mission = 'mission.json'; writeFileSync(join(root, d.mission), JSON.stringify({ missionVersion: 1, id: 'valid', objective: 'hello', capabilities: { tools: ['read', 'read'] } })); }
|
||||
else d.capabilities = { tools: ['read', 'read'] };
|
||||
const file = join(root, 'task.json'); writeFileSync(file, JSON.stringify(d));
|
||||
const r = spawnSync(process.execPath, [join(repo, 'scripts/mosaic-task.mjs'), 'validate', file], { encoding: 'utf8', env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
|
||||
assert.equal(r.status, 2); assert.match(r.stderr, /duplicate tool in/); assert.doesNotMatch(r.stderr, /read/);
|
||||
});
|
||||
for (const childStderr of [secret, `mosaic-config: execution.model must match ${secret}`]) test(`config-command does not relay arbitrary child stderr${childStderr.startsWith('mosaic-config:') ? ' after a known field' : ''}`, t => {
|
||||
const root = scratch(t), file = join(root, 'config.json');
|
||||
const code = `import cp from 'node:child_process'; import { syncBuiltinESMExports } from 'node:module';
|
||||
cp.spawnSync = () => ({ status: 2, stderr: ${JSON.stringify(childStderr)}, stdout: '' }); syncBuiltinESMExports();
|
||||
const { runConfigCommand } = await import(${JSON.stringify(new URL('../../organization/src/config-command.mjs', import.meta.url).href)});
|
||||
process.exitCode = runConfigCommand(['validate', 'acme'], { env: { MOSAIC_CONFIG: ${JSON.stringify(file)} } });`;
|
||||
const r = spawnSync(process.execPath, ['--input-type=module', '-e', code], { encoding: 'utf8', env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
|
||||
assert.equal(r.status, 3, r.stderr); assert.ok(r.stderr.includes(file), r.stderr); assert.doesNotMatch(r.stderr, /sk-test|NOT-A-SECRET/);
|
||||
});
|
||||
|
||||
import { bootConfig, loadSystem } from '../src/config.mjs';
|
||||
import { CliError } from '../src/errors.mjs';
|
||||
test('CLI boot project id mismatch omits the configured value and keeps exit 3', t => {
|
||||
const root = scratch(t), f = fixture(root), file = join(f.doc.projects.stack.root, '.mosaic', 'project.json');
|
||||
mkdirSync(join(f.doc.projects.stack.root, '.mosaic'));
|
||||
writeFileSync(file, JSON.stringify({ projectVersion: 1, id: secret.toLowerCase() }));
|
||||
assert.throws(() => bootConfig({ system: loadSystem({ env: f.env }), businessId: 'acme', env: f.env }), safe(file, CliError, { exitCode: 3 }, secret.toLowerCase()));
|
||||
});
|
||||
@@ -369,8 +369,8 @@ test("a launched pm's request_staff reaches the chief of staff; no other session
|
||||
const bad = await call("pm", `TOOL request_staff ${JSON.stringify({ task_ref: "vikunja:1/2", roles: [{ role: "coder", count: 0 }] })}`);
|
||||
assert.match(bad.body, /invalid-request/);
|
||||
// The chief of staff has no request_staff, and a pm has no launch.
|
||||
assert.equal((await call("chief", 'TOOL request_staff {"task_ref":"vikunja:1/2","roles":[{"role":"coder","count":1}]}')).body, "refused: unknown-tool");
|
||||
assert.equal((await call("pm", 'TOOL launch {"instance":"coder"}')).body, "refused: unknown-tool");
|
||||
assert.equal((await call("chief", 'TOOL request_staff {"task_ref":"vikunja:1/2","roles":[{"role":"coder","count":1}]}')).body.split("\n")[0], "refused: unknown-tool");
|
||||
assert.equal((await call("pm", 'TOOL launch {"instance":"coder"}')).body.split("\n")[0], "refused: unknown-tool");
|
||||
// Nothing was launched by asking: the chief of staff's limits are the same, and sonnet has one place left.
|
||||
assert.deepEqual(readSessions(f.dataRoot).map((s) => s.instance).sort(), ["chief", "pm"]);
|
||||
const launched = await call("chief", 'TOOL launch {"instance":"coder"}');
|
||||
|
||||
@@ -867,7 +867,7 @@ test("row 194 D3 item 5: the chief's session hires with its tool and launches it
|
||||
assert.equal(listed.launched, true);
|
||||
// Hire vars set only model and limits.* (N3). harness and thinking are agent-layer route vars the
|
||||
// human sets, so until then the launch refuses no-route.
|
||||
assert.equal(await ask("launch", { instance: "sec-2" }), "refused: no-route");
|
||||
assert.equal((await ask("launch", { instance: "sec-2" })).split("\n")[0], "refused: no-route");
|
||||
const route = { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" };
|
||||
s.call("instances.set", { revision: s.call("instances").revision, instance: "sec-2", op: "change", vars: route });
|
||||
const launched = JSON.parse(await ask("launch", { instance: "sec-2" }));
|
||||
@@ -875,13 +875,13 @@ test("row 194 D3 item 5: the chief's session hires with its tool and launches it
|
||||
assert.deepEqual(s.running("sec-2").map((x) => x.run), [launched.run]);
|
||||
assert.equal(readSessions(p.f.dataRoot).find((x) => x.run === launched.run).launchedBy, "chief");
|
||||
// The schema's pattern guides the model. The host refuses any name its file doesn't list.
|
||||
assert.equal(await ask("launch", { instance: "NOT A NAME" }), "refused: instance-not-listed");
|
||||
assert.equal(await ask("launch", { instance: "reviewer" }), "refused: instance-not-listed");
|
||||
assert.equal((await ask("launch", { instance: "NOT A NAME" })).split("\n")[0], "refused: instance-not-listed");
|
||||
assert.equal((await ask("launch", { instance: "reviewer" })).split("\n")[0], "refused: instance-not-listed");
|
||||
await s.claimed(launched.run);
|
||||
const fired = JSON.parse(await ask("fire", { name: "sec-2", reason: "the review is done" }));
|
||||
assert.ok(fired.transaction, JSON.stringify(fired));
|
||||
await until(() => s.running("sec-2").length === 0, 30000);
|
||||
// A fired instance stays in roles, off the listing, so the chief can't launch it again.
|
||||
assert.equal(s.call("instances").instances["sec-2"].launched, false);
|
||||
assert.equal(await ask("launch", { instance: "sec-2" }), "refused: instance-not-listed");
|
||||
assert.equal((await ask("launch", { instance: "sec-2" })).split("\n")[0], "refused: instance-not-listed");
|
||||
});
|
||||
@@ -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"));
|
||||
|
||||
@@ -96,7 +96,7 @@ function onlyKeys(obj, allowed, where) {
|
||||
function requireString(obj, key, where, pattern, what) {
|
||||
const v = obj[key];
|
||||
if (typeof v !== "string" || v.length === 0) throw new DiscordError(`${where}: ${key} must be a non-empty string`);
|
||||
if (pattern && !pattern.test(v)) throw new DiscordError(`${where}: ${key} is not ${what}: ${JSON.stringify(v)}`);
|
||||
if (pattern && !pattern.test(v)) throw new DiscordError(`${where}: ${key} is not ${what}`);
|
||||
return v;
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ export function validateBinding(raw, where = "binding") {
|
||||
if (!Array.isArray(u.channels) || u.channels.length === 0) throw new DiscordError(`${w}: channels must be a non-empty array of listed channel ids`);
|
||||
allowed = u.channels.map((cid, j) => {
|
||||
if (typeof cid !== "string" || !SNOWFLAKE.test(cid)) throw new DiscordError(`${w}.channels[${j}]: not a Discord snowflake id`);
|
||||
if (!channels.some((c) => c.id === cid)) throw new DiscordError(`${w}.channels[${j}]: ${cid} is not a listed channel`);
|
||||
if (!channels.some((c) => c.id === cid)) throw new DiscordError(`${w}.channels[${j}]: is not a listed channel`);
|
||||
return cid;
|
||||
});
|
||||
if (new Set(allowed).size !== allowed.length) throw new DiscordError(`${w}: duplicate channel id`);
|
||||
@@ -204,7 +204,7 @@ export function validateBinding(raw, where = "binding") {
|
||||
const rname = requireString(r, "name", w, ROOT_NAME, "a root name");
|
||||
const rpath = requireString(r, "path", w);
|
||||
if (!isAbsolute(rpath) || rpath.includes("\0")) throw new DiscordError(`${w}: path must be an absolute path`);
|
||||
if (rpath.split(sep).some((seg) => seg.startsWith(".") && seg.length > 0)) throw new DiscordError(`${w}: path must not have a dot-prefixed segment (${rpath})`);
|
||||
if (rpath.split(sep).some((seg) => seg.startsWith(".") && seg.length > 0)) throw new DiscordError(`${w}: path must not have a dot-prefixed segment`);
|
||||
if (resolve(rpath) === sep || resolve(rpath) === homedir()) throw new DiscordError(`${w}: path must not be the filesystem root or the home directory`);
|
||||
if (r.write !== undefined && r.write !== true && r.write !== false) throw new DiscordError(`${w}: write must be true or false`);
|
||||
let git = null;
|
||||
@@ -343,7 +343,7 @@ function setsparkConfig(raw, where, users) {
|
||||
const approvers = {};
|
||||
for (const u of users) {
|
||||
const name = u.name.trim().toLowerCase();
|
||||
if (Object.hasOwn(approvers, name)) throw new DiscordError(`${where}: two users named ${name}; approver names must be distinct`);
|
||||
if (Object.hasOwn(approvers, name)) throw new DiscordError(`${where}: two users share a name; approver names must be distinct (case-insensitive)`);
|
||||
approvers[name] = u.id;
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -46,7 +46,7 @@ export function createGateway({
|
||||
random = Math.random,
|
||||
properties = { os: process.platform, browser: "mosaic-stack", device: "mosaic-stack" },
|
||||
} = {}) {
|
||||
if (typeof url !== "string" || !url.startsWith("wss://")) throw new DiscordError(`gateway: url must be wss://, got ${JSON.stringify(url)}`, 1);
|
||||
if (typeof url !== "string" || !url.startsWith("wss://")) throw new DiscordError(`gateway: url must be wss://`, 1);
|
||||
if (typeof token !== "string" || token.length === 0) throw new DiscordError("gateway: token required", 1);
|
||||
if (typeof WebSocketImpl !== "function") throw new DiscordError("gateway: WebSocket implementation required", 1);
|
||||
|
||||
|
||||
@@ -131,7 +131,8 @@ export function writeTurn(dir, id, record) {
|
||||
}
|
||||
|
||||
export function readTurn(dir, id) {
|
||||
return JSON.parse(readFileSync(turnPath(dir, id), "utf8"));
|
||||
const path = turnPath(dir, id);
|
||||
return parseJsonFileText(readFileSync(path, "utf8"), path, "turn record");
|
||||
}
|
||||
|
||||
export function listTurns(dir) {
|
||||
@@ -139,7 +140,7 @@ export function listTurns(dir) {
|
||||
if (!existsSync(turns)) return [];
|
||||
return readdirSync(turns)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.map((f) => JSON.parse(readFileSync(join(turns, f), "utf8")));
|
||||
.map((f) => parseJsonFileText(readFileSync(join(turns, f), "utf8"), join(turns, f), "turn record"));
|
||||
}
|
||||
|
||||
// The daily ceiling counts admissions on the current UTC date. An admission
|
||||
|
||||
@@ -96,7 +96,7 @@ export function loadSetsparkConfig(raw, where = "setspark") {
|
||||
function loadApprovers(raw, where) {
|
||||
if (!isObject(raw) || Object.keys(raw).length > 64) throw new Error(`${where}: must be an object of at most 64 names`);
|
||||
for (const [name, id] of Object.entries(raw)) {
|
||||
if (name.length === 0 || name.length > 64 || name !== name.trim().toLowerCase() || /[\u0000-\u001f\u007f]/.test(name)) throw new Error(`${where}: ${JSON.stringify(name.slice(0, 64))} must be a trimmed lower-case name`);
|
||||
if (name.length === 0 || name.length > 64 || name !== name.trim().toLowerCase() || /[\u0000-\u001f\u007f]/.test(name)) throw new Error(`${where}: an approver name must be trimmed, lower-case, 1-64 characters, with no control characters`);
|
||||
if (typeof id !== "string" || !SNOWFLAKE.test(id)) throw new Error(`${where}.${name}: must be a Discord user id`);
|
||||
}
|
||||
if (new Set(Object.values(raw)).size !== Object.keys(raw).length) throw new Error(`${where}: one Discord user id under two names`);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
function scratch(t) { const root = mkdtempSync(join(tmpdir(), 'r4-')); t.after(() => rmSync(root, { recursive: true, force: true })); return root; }
|
||||
function safe(label, Type, props = {}, omitted = secret) { return error => {
|
||||
assert.ok(error instanceof Type, error.stack);
|
||||
for (const [key, value] of Object.entries(props)) assert.equal(error[key], value);
|
||||
assert.ok(error.message.includes(label), error.message);
|
||||
assert.doesNotMatch(error.message + error.stack, /sk-test|NOT-A-SECRET|Unexpected token/);
|
||||
assert.ok(!error.message.includes(omitted), error.message);
|
||||
assert.equal(error.cause, undefined);
|
||||
return true;
|
||||
}; }
|
||||
import { validateBinding } from '../src/binding.mjs';
|
||||
import { createGateway } from '../src/gateway.mjs';
|
||||
import { readTurn, listTurns } from '../src/journal.mjs';
|
||||
import { DiscordError } from '../src/errors.mjs';
|
||||
import { rawBinding } from './helpers.mjs';
|
||||
for (const [field, edit] of [
|
||||
['guildId', d => { d.guildId = secret; }],
|
||||
['name', d => { d.name = secret + '/'; }],
|
||||
['path', d => { d.tools = { roots: [{ name: 'root', path: `/x/.${secret}`, write: false }] }; }],
|
||||
]) test(`binding ${field} refusal omits its configured value`, () => {
|
||||
const d = rawBinding(); edit(d); assert.throws(() => validateBinding(d), safe(field, DiscordError, { exitCode: 2 }));
|
||||
});
|
||||
test('gateway url refusal omits its value', () => assert.throws(() => createGateway({ url: secret, token: 'fake' }), safe('url', DiscordError, { exitCode: 1 })));
|
||||
for (const mode of ['read', 'list']) test(`turn ${mode} parser omits file bytes and keeps SyntaxError`, t => {
|
||||
const root = scratch(t); mkdirSync(join(root, 'turns'));
|
||||
const file = join(root, 'turns', 'turn-1.json'); writeFileSync(file, secret);
|
||||
assert.throws(() => mode === 'read' ? readTurn(root, 'turn-1') : listTurns(root), safe(file, SyntaxError));
|
||||
});
|
||||
|
||||
for (const duplicate of [true, false]) test(`binding approver ${duplicate ? 'duplicate' : 'invalid'} name omits user values`, t => {
|
||||
const root = scratch(t), keyFile = join(root, 'fixture-key'); writeFileSync(keyFile, 'fixture\n', { mode: 0o600 });
|
||||
const name = secret.toLowerCase() + (duplicate ? '' : '\u0001');
|
||||
const users = [{ id: '100000000000000100', name }];
|
||||
if (duplicate) users.push({ id: '100000000000000101', name });
|
||||
const raw = rawBinding({ users, tools: { roots: [{ name: 'docs', path: root }], setspark: { baseUrl: 'https://api.setspark.io', keyFile, principal: 'sage' } } });
|
||||
assert.throws(() => validateBinding(raw), safe(duplicate ? 'users' : 'approvers', DiscordError, { exitCode: 2 }, secret.toLowerCase()));
|
||||
});
|
||||
@@ -157,7 +157,7 @@ test("setspark config: approvers come from the binding's users, never from the b
|
||||
const b = validateBinding(rawBinding({ users, tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage" } } }));
|
||||
assert.deepEqual(b.tools.setspark.approvers, { jason: "100000000000000100", carmen: "100000000000000101" });
|
||||
assert.throws(() => validateBinding(rawBinding({ users, tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage", approvers: { mallory: "100000000000000199" } } } })), /approvers come from users/);
|
||||
assert.throws(() => validateBinding(rawBinding({ users: [{ id: "100000000000000100", name: "Jason" }, { id: "100000000000000101", name: "jason" }], tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage" } } })), /two users named jason/);
|
||||
assert.throws(() => validateBinding(rawBinding({ users: [{ id: "100000000000000100", name: "Jason" }, { id: "100000000000000101", name: "jason" }], tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage" } } })), /two users share a name/);
|
||||
const withCarmenOut = validateBinding(rawBinding({ users: [users[0]], tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage" } } }));
|
||||
assert.throws(() => reloadDiff(b, withCarmenOut), /tools cannot change/, "pi's approvers are fixed at start, so a user change needs a restart");
|
||||
const channelsOnly = validateBinding(rawBinding({ users: [{ ...users[0], channels: [rawBinding().channels[0].id] }, users[1]], tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage" } } }));
|
||||
|
||||
@@ -14,6 +14,7 @@ import { createHash } from "node:crypto";
|
||||
import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { railsText, ownRails } from "../../organization/src/rails.mjs";
|
||||
import { typedTools, untypedActions } from "./tools.mjs";
|
||||
import { route as defaultProxyRoute } from "../../plugins/model-provider/cliproxy/index.mjs";
|
||||
import { CREATIVE_DEFINITIONS } from "../../organization/src/vocabulary.mjs";
|
||||
@@ -281,7 +282,7 @@ function prompt({ contract, resolved, session, tools, untyped, builtins, persona
|
||||
...checkoutLines(session.checkouts ?? []),
|
||||
// Row 89 (#1583): the names the harness gives its built-ins, as the manifest lists them.
|
||||
`- Built-in tools: ${builtins.join(", ") || "none"}.`,
|
||||
`- Bus tools: ${tools.map((t) => t.name).join(", ")}. The broker decides every call; a refusal names its reason.`,
|
||||
`- Bus tools: ${tools.map((t) => t.name).join(", ")}. The broker decides every call; a refusal carries its code, the rail it enforces and the next step (plus a reason when available).`,
|
||||
"- Each message to you arrives as one turn. Your final answer goes back to the sender as the reply, so end each turn with the answer itself.",
|
||||
"- You never hold a token. You act on the tracker, the forge and other roles only through the bus tools.",
|
||||
];
|
||||
@@ -306,6 +307,7 @@ function prompt({ contract, resolved, session, tools, untyped, builtins, persona
|
||||
if (named.has("request_staff"))
|
||||
lines.push("- Staff: you don't launch sessions. For intake or a task, request_staff takes exactly one of request or task_ref and names each role with how many. The chief of staff answers by message with the instances it launched, or with the refusal and its reason.");
|
||||
if (untyped.length) lines.push(`- Actions you hold that have no bus tool yet: ${untyped.join(", ")}. Say so instead of working around it.`);
|
||||
lines.push("", railsText(resolved.rails ?? ownRails({ instance: resolved.instance, authority: resolved.limits.authority, network: resolved.limits.network })), "");
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
// capability or the claim stopped working (ended, revoked), 23 the broker
|
||||
// stayed unreachable, 2 a bad run directory.
|
||||
|
||||
import { refusalGuidance } from "../../bus/src/refusals.mjs";
|
||||
import { spawn } from "node:child_process";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { chmodSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, closeSync } from "node:fs";
|
||||
@@ -366,6 +367,7 @@ export function toolServer({ path, tools, client, local }) {
|
||||
answer({ ok: true, result });
|
||||
} catch (e) {
|
||||
answer({ ok: false, error: /^[a-z-]{1,64}$/.test(e.code ?? "") ? e.code : "tool-failed",
|
||||
...refusalGuidance(e.code),
|
||||
...(typeof e.reason === "string" && request.tool === "advance_request" && tool.verb === "request.advance" && e.code === "request-evidence-required"
|
||||
? { reason: e.reason.slice(0, 300) } : tool.verb === "record.write" && e.code === "project-record-invalid" && typeof e.reason === "string"
|
||||
? { reason: e.reason.slice(0, 300) } : {}) });
|
||||
@@ -523,14 +525,20 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
|
||||
process.on("SIGINT", stop);
|
||||
|
||||
let session, cap, policy;
|
||||
let input = "session.json";
|
||||
try {
|
||||
session = { ...DEFAULTS, ...JSON.parse(readFileSync(join(runDir, "session.json"), "utf8")), runDir,
|
||||
proxyFirstTurn: existsSync(join(runDir, "proxy-first-turn-enabled")) };
|
||||
input = "stdin capability";
|
||||
cap = JSON.parse(await readLine(stdin)).cap;
|
||||
if (typeof cap !== "string" || !/^[0-9a-f]{64}$/.test(cap)) throw new Error("no capability on stdin");
|
||||
input = "policy.json";
|
||||
policy = JSON.parse(readFileSync(session.bundle.policy, "utf8"));
|
||||
} catch (e) {
|
||||
log(`runner: ${e.message}`);
|
||||
const reason = e.message === "stdin too large" ? "stdin too large (expected at most 4096 characters)"
|
||||
: e.message === "no capability on stdin" ? "no capability on stdin (expected cap with 64 lowercase hex characters)"
|
||||
: `${input} must be readable JSON with the expected session, capability or policy fields`;
|
||||
log(`runner: ${e.code ? `${e.code}: ` : ""}${reason}`);
|
||||
return EXIT.usage;
|
||||
}
|
||||
if (!landingOk(session.landing)) {
|
||||
@@ -567,7 +575,13 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
|
||||
}
|
||||
log(`runner: ${session.business}/${session.instance} claimed by run ${session.run}`);
|
||||
|
||||
const tools = JSON.parse(readFileSync(session.bundle.tools, "utf8"));
|
||||
let tools;
|
||||
try {
|
||||
tools = JSON.parse(readFileSync(session.bundle.tools, "utf8"));
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) throw new SyntaxError("tools.json must be valid JSON");
|
||||
throw error;
|
||||
}
|
||||
const server = await toolServer({
|
||||
path: session.toolSocket,
|
||||
tools,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
// socket (MOSAIC_TOOL_SOCKET) with `{tool, args}`; the runner checks the
|
||||
// name against this list and forwards the call with its own capability.
|
||||
|
||||
import { refusalGuidance } from "../../bus/src/refusals.mjs";
|
||||
import { connect } from "node:net";
|
||||
import { ACTIONS, ID_PATTERN, THREAD_DEFINITION, CREATIVE_DEFINITIONS } from "../../organization/src/vocabulary.mjs";
|
||||
|
||||
@@ -372,10 +373,11 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project
|
||||
requirement_snapshot: str("Exact approved snapshot ID; omit to read the active snapshot"),
|
||||
}));
|
||||
}
|
||||
add("my_rails", "my_rails", null, "Read only your current actions, routes, resolve levels, network, credential service/scope names and messaging limits. No values or other agents' rails.", obj({}));
|
||||
// mos-1308 / M3: no generic spec, evidence, tracker, decision or write tools. The
|
||||
// C2-08 web grant still supplies broker web reads to companion and research only.
|
||||
if (CREATIVE_DEFINITIONS.includes(resolved.definition))
|
||||
return tools.filter(tool => tool.name === "send_message" || tool.name.startsWith("library_") ||
|
||||
return tools.filter(tool => tool.name === "my_rails" || tool.name === "send_message" || tool.name.startsWith("library_") ||
|
||||
tool.name === "web_search" || tool.name === "web_fetch");
|
||||
return tools;
|
||||
}
|
||||
@@ -429,7 +431,13 @@ export function callTool(path, tool, args = {}, { timeout = 60_000 } = {}) {
|
||||
if (!input.includes("\n")) return;
|
||||
try {
|
||||
const r = JSON.parse(input);
|
||||
r.ok ? end(null, r.result) : end(new Error(tool === "intake_question" && r.error === "outcome-unknown" ? "outcome-unknown" : tool === "launch" && r.error === "instance-running" ? "refused: instance-running: target instance is already running and will receive its waiting messages when it claims; you must not launch it again or relay the notice" : `refused: ${r.error}${typeof r.reason === "string" && tool === "advance_request" && r.error === "request-evidence-required" ? `: ${r.reason.slice(0, 300)}` : tool === "write_record" && r.error === "project-record-invalid" && typeof r.reason === "string" ? `: ${r.reason.slice(0, 300)}` : ""}`));
|
||||
if (r.ok) return end(null, r.result);
|
||||
const guidance = refusalGuidance(r.error);
|
||||
const error = new Error(tool === "intake_question" && r.error === "outcome-unknown" ? "outcome-unknown" : tool === "launch" && r.error === "instance-running" ? "refused: instance-running: target instance is already running and will receive its waiting messages when it claims; you must not launch it again or relay the notice" : `refused: ${r.error}${typeof r.reason === "string" && tool === "advance_request" && r.error === "request-evidence-required" ? `: ${r.reason.slice(0, 300)}` : tool === "write_record" && r.error === "project-record-invalid" && typeof r.reason === "string" ? `: ${r.reason.slice(0, 300)}` : ""}`);
|
||||
error.code = r.error;
|
||||
Object.assign(error, guidance);
|
||||
error.message += `\nRail: ${guidance.rail}\nNext step: ${guidance.nextStep}`;
|
||||
end(error);
|
||||
} catch {
|
||||
end(unknown("tool reply isn't JSON"));
|
||||
}
|
||||
|
||||
@@ -200,7 +200,7 @@ test("a chief-of-staff bundle lists end_run, change_organization and set_name as
|
||||
const r = resolvedFor(t, "chief", (d) => (d.roles.chief.vars = { harness, model: "claude-opus-5-5" }));
|
||||
const b = build(t, r);
|
||||
const prefix = harness === "pi" ? "" : "mcp__mosaic__";
|
||||
assert.deepEqual(b.manifest.typed, ["send_message", "launch", "end_run", "hire", "fire", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"].map((n) => prefix + n), harness);
|
||||
assert.deepEqual(b.manifest.typed, ["send_message", "launch", "end_run", "hire", "fire", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "my_rails"].map((n) => prefix + n), harness);
|
||||
assert.deepEqual(b.manifest.untyped, ["decision.resolve.technical", "decision.resolve.delivery"], harness);
|
||||
assert.match(readFileSync(b.files.prompt, "utf8"), /Bus tools: send_message, launch, end_run, hire, fire, organization_revision, change_organization, set_name, read_spec, write_record, raise_decision, /);
|
||||
assert.match(readFileSync(b.files.prompt, "utf8"), /Actions you hold that have no bus tool yet/);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
function scratch(t) { const root = mkdtempSync(join(tmpdir(), 'r4-')); t.after(() => rmSync(root, { recursive: true, force: true })); return root; }
|
||||
function safe(label, Type, props = {}, omitted = secret) { return error => {
|
||||
assert.ok(error instanceof Type, error.stack);
|
||||
for (const [key, value] of Object.entries(props)) assert.equal(error[key], value);
|
||||
assert.ok(error.message.includes(label), error.message);
|
||||
assert.doesNotMatch(error.message + error.stack, /sk-test|NOT-A-SECRET|Unexpected token/);
|
||||
assert.ok(!error.message.includes(omitted), error.message);
|
||||
assert.equal(error.cause, undefined);
|
||||
return true;
|
||||
}; }
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { EXIT } from '../src/runner.mjs';
|
||||
const runner = join(import.meta.dirname, '../src/runner.mjs');
|
||||
for (const at of ['session.json', 'stdin', 'policy.json']) test(`runner ${at} failure omits parser bytes and keeps usage exit`, t => {
|
||||
const root = scratch(t), policy = join(root, 'policy.json');
|
||||
writeFileSync(join(root, 'session.json'), at === 'session.json' ? secret : JSON.stringify({ bundle: { policy } }));
|
||||
writeFileSync(policy, at === 'policy.json' ? secret : '{}');
|
||||
const r = spawnSync(process.execPath, [runner, root], { encoding: 'utf8', input: at === 'stdin' ? secret + '\n' : JSON.stringify({ cap: 'a'.repeat(64) }) + '\n', env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
|
||||
assert.equal(r.status, EXIT.usage, r.stderr);
|
||||
assert.ok(r.stderr.includes(at), r.stderr);
|
||||
assert.doesNotMatch(r.stdout + r.stderr, /sk-test|NOT-A-SECRET|Unexpected token/);
|
||||
});
|
||||
test('runner keeps a read errno without relaying its message', t => {
|
||||
const root = scratch(t); mkdirSync(join(root, 'session.json'));
|
||||
const r = spawnSync(process.execPath, [runner, root], { encoding: 'utf8', input: '', env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
|
||||
assert.equal(r.status, EXIT.usage); assert.match(r.stderr, /session.json/); assert.match(r.stderr, /runner: EISDIR:/);
|
||||
});
|
||||
test('runner tools parser keeps SyntaxError without file bytes', t => {
|
||||
const root = scratch(t), policy = join(root, 'policy.json'), tools = join(root, 'tools.json');
|
||||
writeFileSync(join(root, 'session.json'), JSON.stringify({ bundle: { policy, tools }, turnEnd: 'exit', harnessEnv: {} }));
|
||||
writeFileSync(policy, '{"needs":[]}'); writeFileSync(tools, secret);
|
||||
// Isolate the claim boundary: this test exercises the real post-claim file parser,
|
||||
// and intentionally never starts a broker, adapter or tool server.
|
||||
const code = `import { Readable } from 'node:stream';
|
||||
import { Client } from ${JSON.stringify(new URL('../../bus/src/client.mjs', import.meta.url).href)};
|
||||
import { main } from ${JSON.stringify(new URL('../src/runner.mjs', import.meta.url).href)};
|
||||
Client.prototype.call = async () => ({});
|
||||
try { await main(${JSON.stringify(root)}, { stdin: Readable.from([JSON.stringify({ cap: 'a'.repeat(64) }) + '\\n']), env: {}, log() {} }); }
|
||||
catch (e) { if (!(e instanceof SyntaxError)) throw e; console.error(e.stack); process.exitCode = 9; }`;
|
||||
const r = spawnSync(process.execPath, ['--input-type=module', '-e', code], { encoding: 'utf8', env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
|
||||
assert.equal(r.status, 9, r.stderr); assert.match(r.stderr, /tools.json/); assert.doesNotMatch(r.stderr, /sk-test|NOT-A-SECRET|Unexpected token/);
|
||||
});
|
||||
@@ -14,9 +14,9 @@ const role = (t, definition, harness = 'pi') => resolvedFor(t, 'bean', d => {
|
||||
d.roles.bean = { definition, credentials: { gitea: { ...d.roles.coder.credentials.gitea } }, vars: { harness, model: 'claude-sonnet-5-5', thinking: 'high' } };
|
||||
});
|
||||
const expected = definition => ['send_message', ...(['companion', 'research'].includes(definition)
|
||||
? ['web_search', 'web_fetch'] : []), ...LOOKUPS, 'library_doc_write', 'library_doc_delete'];
|
||||
? ['web_search', 'web_fetch'] : []), ...LOOKUPS, 'library_doc_write', 'library_doc_delete', 'my_rails'];
|
||||
|
||||
test('M3: per-role snapshots contain eight names, or ten with the C2-08 web grant', t => {
|
||||
test('M3/TI: per-role snapshots contain nine names, or eleven with the C2-08 web grant', t => {
|
||||
for (const definition of CREATIVE) {
|
||||
const tools = typedTools({ resolved: role(t, definition), library: true, tracker: true, arbiter: true });
|
||||
assert.deepEqual(tools.map(t => t.name), expected(definition), definition);
|
||||
@@ -72,7 +72,7 @@ test('bundle flag reaches both harness tool manifests and creative prompt withou
|
||||
|
||||
test('C2-05c scope schemas expand only find/search/get, keeping manuscript and tool names unchanged', t => {
|
||||
const tools = typedTools({ resolved: role(t, 'ideas'), library: true });
|
||||
assert.deepEqual(tools.map(t => t.name), ['send_message', ...LOOKUPS, 'library_doc_write', 'library_doc_delete']);
|
||||
assert.deepEqual(tools.map(t => t.name), ['send_message', ...LOOKUPS, 'library_doc_write', 'library_doc_delete', 'my_rails']);
|
||||
for (const name of ['library_find', 'library_search', 'library_get']) {
|
||||
const tool = tools.find(t => t.name === name);
|
||||
assert.deepEqual(tool.parameters.properties.scope.enum, ['canon', 'playground', 'supporting']);
|
||||
|
||||
@@ -11,12 +11,12 @@ const role = (t, definition) => resolvedFor(t, 'bean', doc => {
|
||||
doc.roles.bean = { definition, credentials: { gitea: { ...doc.roles.coder.credentials.gitea } }, vars: { harness: 'pi', model: 'claude-sonnet-5-5', thinking: 'high' } };
|
||||
});
|
||||
|
||||
test('C2-02 M3: each creative role has eight tools, or ten with its web grant', t => {
|
||||
test('C2-02 M3/TI: each creative role has nine tools, or eleven with its web grant', t => {
|
||||
for (const definition of CREATIVE) {
|
||||
const resolved = role(t, definition), tools = typedTools({ resolved, library: true, tracker: true, arbiter: true });
|
||||
const expected = ['send_message', ...(['companion', 'research'].includes(definition) ? ['web_search', 'web_fetch'] : []), ...LOOKUPS, ...WRITES];
|
||||
const expected = ['send_message', ...(['companion', 'research'].includes(definition) ? ['web_search', 'web_fetch'] : []), ...LOOKUPS, ...WRITES, 'my_rails'];
|
||||
assert.deepEqual(tools.map(tool => tool.name), expected, definition);
|
||||
assert.equal(tools.length, ['companion', 'research'].includes(definition) ? 10 : 8);
|
||||
assert.equal(tools.length, ['companion', 'research'].includes(definition) ? 11 : 9);
|
||||
assert.equal(untypedActions(resolved, tools).includes('library.write'), false);
|
||||
}
|
||||
});
|
||||
@@ -56,5 +56,5 @@ test('C2-02 tools: no library, noncreative or no within-role write grant exposes
|
||||
const narrowed = structuredClone(creative);
|
||||
narrowed.limits.authority.withinRole = narrowed.limits.authority.withinRole.filter(action => action !== 'library.write');
|
||||
narrowed.limits.authority.crossRole.push('library.write');
|
||||
assert.deepEqual(typedTools({ resolved: narrowed, library: true }).map(tool => tool.name), ['send_message', ...LOOKUPS]);
|
||||
assert.deepEqual(typedTools({ resolved: narrowed, library: true }).map(tool => tool.name), ['send_message', ...LOOKUPS, 'my_rails']);
|
||||
});
|
||||
@@ -68,7 +68,7 @@ test("tools/call goes through the tool socket; a refusal is an isError result",
|
||||
assert.deepEqual(JSON.parse(ok.content[0].text), { tool: "list_agents", args: { scope: "all" } });
|
||||
const refused = (await server.send("tools/call", { name: "launch", arguments: { instance: "coder" } })).result;
|
||||
assert.equal(refused.isError, true);
|
||||
assert.equal(refused.content[0].text, "refused: capacity-full");
|
||||
assert.match(refused.content[0].text, /^refused: capacity-full\nRail: .+\nNext step: .+/);
|
||||
assert.deepEqual(sock.calls.map((c) => c.tool), ["list_agents", "launch"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { buildBundle } from '../src/bundle.mjs';
|
||||
import { typedTools } from '../src/tools.mjs';
|
||||
import { resolvedFor, scratch, HARNESS, REPO_SKILLS } from './helpers.mjs';
|
||||
import { ownRails, railsText, decisionAutonomy } from '../../organization/src/rails.mjs';
|
||||
|
||||
const SECRET = 'sk-test-NOT-A-SECRET-1234';
|
||||
test('coder prompt lists every held action with its route and only service/scope names', t => {
|
||||
const resolved = resolvedFor(t, 'coder');
|
||||
const root = scratch(t), workspace = join(root, 'ws'); mkdirSync(workspace);
|
||||
const bundle = buildBundle({ dir: join(root, 'bundle'), resolved,
|
||||
contract: '# Contract', harness: HARNESS.pi, skillsDir: REPO_SKILLS,
|
||||
session: { run: 'r1', workspace, toolSocket: join(root, 'tool.sock'), turnMarker: join(root, 'turn.done') } });
|
||||
const prompt = readFileSync(bundle.files.prompt, 'utf8');
|
||||
assert.ok(prompt.includes(railsText(resolved.rails)));
|
||||
for (const action of resolved.limits.authority.withinRole) assert.ok(prompt.includes(`- ${action}: alone.`));
|
||||
assert.match(prompt, /task.scope.change: delivery \(default\): arbiter instance "pm"; technical: arbiter instance "cto"/);
|
||||
assert.match(prompt, /Network: none/);
|
||||
assert.match(prompt, /Credential service gitea; scopes:.*write:repository/);
|
||||
assert.ok(!railsText(resolved.rails).includes(resolved.credentials.gitea.file));
|
||||
assert.ok(!railsText(resolved.rails).includes(SECRET));
|
||||
assert.match(prompt, /a refusal carries its code, the rail it enforces and the next step/);
|
||||
});
|
||||
|
||||
test('all roles including creative roles receive a parameterless caller-only my_rails tool', t => {
|
||||
for (const instance of ['coder', 'chief', 'reviewer']) {
|
||||
const tool = typedTools({ resolved: resolvedFor(t, instance) }).find(t => t.name === 'my_rails');
|
||||
assert.equal(tool.verb, 'my_rails');
|
||||
assert.deepEqual(tool.parameters, { type: 'object', properties: {}, required: [], additionalProperties: false });
|
||||
}
|
||||
const creative = typedTools({ resolved: { ...resolvedFor(t, 'coder'), definition: 'companion' } });
|
||||
assert.ok(creative.some(t => t.name === 'my_rails'));
|
||||
});
|
||||
|
||||
test('resolve rights name their classes and fixed levels through the AU extension point', t => {
|
||||
const { rails } = resolvedFor(t, 'cto');
|
||||
assert.deepEqual(rails.resolve, [{ right: 'decision.resolve.technical', classes: [
|
||||
{ name: 'cross-role', level: decisionAutonomy('cross-role') }, { name: 'merge', level: decisionAutonomy('merge') },
|
||||
] }]);
|
||||
assert.match(railsText(rails), /merge: out of the loop/);
|
||||
});
|
||||
|
||||
test('fixed domains, arbiter self-raises and hard waits describe existing routes only', () => {
|
||||
const rails = ownRails({ instance: 'cto', authority: { withinRole: ['deploy'],
|
||||
crossRole: ['git.merge.protected', 'policy.change'] }, arbiters: { technical: 'cto', delivery: 'pm' } });
|
||||
assert.deepEqual(rails.actions, [
|
||||
{ action: 'deploy', routes: [{ to: 'human' }] },
|
||||
{ action: 'git.merge.protected', routes: [{ domain: 'technical', to: 'human' }] },
|
||||
{ action: 'policy.change', routes: [{ domain: 'delivery', to: 'pm' }] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('hire and fire are unavailable to a chief that is not the configured launcher', t => {
|
||||
const { rails } = resolvedFor(t, 'chief', doc => { delete doc.launch; });
|
||||
for (const action of ['role.hire', 'role.fire']) {
|
||||
assert.deepEqual(rails.actions.find(a => a.action === action).routes, [{ to: 'unavailable' }]);
|
||||
}
|
||||
assert.match(railsText(rails), /role.hire: unavailable here; only the configured chief can hire or fire/);
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -72,10 +72,10 @@ function checkUser(user, at, ctx, humans, seenSubjects) {
|
||||
requireObject(user, at);
|
||||
rejectUnknownKeys(user, ["id", "home", "subjects"], at);
|
||||
requireId(user.id, `${at}.id`);
|
||||
if (RESERVED_USERS.includes(user.id)) refuse(`${at}.id must not be ${JSON.stringify(user.id)}, a bus principal`);
|
||||
if (humans.has(user.id)) refuse(`${at}.id must not be a business's human (${JSON.stringify(user.id)})`);
|
||||
if (RESERVED_USERS.includes(user.id)) refuse(`${at}.id must not be a bus principal`);
|
||||
if (humans.has(user.id)) refuse(`${at}.id must not be a business's human`);
|
||||
if (user.home !== LOCAL_HOME && !ctx.peers.has(user.home)) {
|
||||
refuse(`${at}.home must be a configured peer or ${JSON.stringify(LOCAL_HOME)} (got ${JSON.stringify(user.home)})`);
|
||||
refuse(`${at}.home must be a configured peer or ${JSON.stringify(LOCAL_HOME)}`);
|
||||
}
|
||||
const subjects = requireArray(user.subjects, `${at}.subjects`);
|
||||
if (subjects.length === 0) refuse(`${at}.subjects must not be empty`);
|
||||
@@ -84,7 +84,7 @@ function checkUser(user, at, ctx, humans, seenSubjects) {
|
||||
requireObject(s, sat);
|
||||
rejectUnknownKeys(s, ["provider", "sub"], sat);
|
||||
if (typeof s.provider !== "string" || !ctx.providers.has(s.provider)) {
|
||||
refuse(`${sat}.provider must be a configured provider (got ${JSON.stringify(s.provider)})`);
|
||||
refuse(`${sat}.provider must be a configured provider`);
|
||||
}
|
||||
requireText(s.sub, `${sat}.sub`);
|
||||
const key = subjectKey(s.provider, s.sub);
|
||||
@@ -105,7 +105,7 @@ function checkScope(value, where, check) {
|
||||
const seen = new Set();
|
||||
list.forEach((entry, i) => {
|
||||
check(entry, `${where}[${i}]`);
|
||||
if (seen.has(entry)) refuse(`duplicate entry in ${where}: ${JSON.stringify(entry)}`);
|
||||
if (seen.has(entry)) refuse(`duplicate entry in ${where}`);
|
||||
seen.add(entry);
|
||||
});
|
||||
}
|
||||
@@ -122,20 +122,20 @@ function checkGrant(grant, at, ctx, users, seenGrants) {
|
||||
requireObject(grant, at);
|
||||
rejectUnknownKeys(grant, ["id", "user", "business", "projects", "instances", "level", "expires"], at);
|
||||
requireId(grant.id, `${at}.id`);
|
||||
if (seenGrants.has(grant.id)) refuse(`${at}.id is a duplicate grant id: ${grant.id}`);
|
||||
if (seenGrants.has(grant.id)) refuse(`${at}.id is a duplicate grant id`);
|
||||
seenGrants.add(grant.id);
|
||||
if (typeof grant.user !== "string" || !users.has(grant.user)) refuse(`${at}.user must name a user in this file (got ${JSON.stringify(grant.user)})`);
|
||||
if (typeof grant.user !== "string" || !users.has(grant.user)) refuse(`${at}.user must name a user in this file`);
|
||||
if (typeof grant.business !== "string" || !ctx.businesses.has(grant.business)) {
|
||||
refuse(`${at}.business must name a declared business (got ${JSON.stringify(grant.business)})`);
|
||||
refuse(`${at}.business must name a declared business`);
|
||||
}
|
||||
const business = ctx.businesses.get(grant.business);
|
||||
checkScope(grant.projects, `${at}.projects`, (p, where) => {
|
||||
if (typeof p !== "string" || !UUID.test(p)) refuse(`${where} must be "*" or a bus project id, a lowercase UUID (got ${JSON.stringify(p)})`);
|
||||
if (typeof p !== "string" || !UUID.test(p)) refuse(`${where} must be "*" or a bus project id, a lowercase UUID`);
|
||||
});
|
||||
checkScope(grant.instances, `${at}.instances`, (x, where) => {
|
||||
if (typeof x !== "string" || !business.instances.has(x)) refuse(`${where} must be "*" or a declared instance of the grant's business`);
|
||||
});
|
||||
if (!LEVELS.includes(grant.level)) refuse(`${at}.level must be one of ${LEVELS.join(", ")} (got ${JSON.stringify(grant.level)})`);
|
||||
if (!LEVELS.includes(grant.level)) refuse(`${at}.level must be one of ${LEVELS.join(", ")}`);
|
||||
if (grant.expires !== undefined) checkExpires(grant.expires, `${at}.expires`);
|
||||
}
|
||||
|
||||
@@ -147,13 +147,13 @@ export function checkAccess(document, context) {
|
||||
const humans = new Set([...ctx.businesses.values()].map((b) => b.human));
|
||||
if (!isPlainObject(document)) refuse("access file must be a JSON object");
|
||||
rejectUnknownKeys(document, ["accessVersion", "revision", "users", "grants"], "access file");
|
||||
if (document.accessVersion !== 1) refuse(`accessVersion must be 1 (got ${JSON.stringify(document.accessVersion)})`);
|
||||
if (document.accessVersion !== 1) refuse(`accessVersion must be 1`);
|
||||
requirePositiveInt(document.revision, "revision");
|
||||
const users = new Set();
|
||||
const seenSubjects = new Set();
|
||||
requireArray(document.users, "users").forEach((user, i) => {
|
||||
checkUser(user, `users[${i}]`, ctx, humans, seenSubjects);
|
||||
if (users.has(user.id)) refuse(`users[${i}].id is a duplicate user id: ${user.id}`);
|
||||
if (users.has(user.id)) refuse(`users[${i}].id is a duplicate user id`);
|
||||
users.add(user.id);
|
||||
});
|
||||
const seenGrants = new Set();
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
// Root and project agent copies (#1873 Phase A, slice A3; design
|
||||
// docs/plans/2026-11-10_agent-levels-design.md sections 4 and 9 item 2).
|
||||
//
|
||||
// When an organization's bus boots it reads the root file once, through loadRootAgents, and its
|
||||
// own organization file through its own writer. Each root agent the organization does not exclude,
|
||||
// and each agent a project defines, becomes a copy. The snapshot this module returns is plain data
|
||||
// and is fixed until the next boot: nothing here, or anything that holds the snapshot, reads the
|
||||
// root file again.
|
||||
//
|
||||
// A copy runs as an ordinary role instance of the organization: withCopies adds it to the
|
||||
// organization document's `roles` and validates the whole document, so every copy goes through the
|
||||
// same checkRoles an org instance takes, with the organization's credential map. A root or project
|
||||
// definition carries no credential (agent-levels.mjs refuses one), and A3 has no way to bind one,
|
||||
// so a copy whose role needs a credential service or a tracker bot is unbound: it is not added, and
|
||||
// a launch refuses it by name, naming the missing services and never a value.
|
||||
//
|
||||
// Nothing in a copy's view carries a var value, a credential reference, a path or file bytes.
|
||||
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { HOST_SERVICE } from "./vocabulary.mjs";
|
||||
import { loadRole } from "./role.mjs";
|
||||
import { resolveAgentLevels } from "./agent-levels.mjs";
|
||||
import { mergeVars } from "./vars.mjs";
|
||||
import { businessFilePath, loadBusiness, validateBusinessDocument } from "./organization-config.mjs";
|
||||
import { organizationWriter } from "./organization.mjs";
|
||||
import { deepFreeze } from "./util.mjs";
|
||||
|
||||
const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
// How a refusal names one claimant of an instance name.
|
||||
const claimLabel = ({ name, project }) => (project === null ? `root agent ${name}` : `project ${project}'s ${name}`);
|
||||
const collision = (key, claimants) => `the instance name ${key} is claimed by ${claimants.map(claimLabel).join(" and ")}`;
|
||||
// The root assistant is shown, not copied (A6); agent-levels.mjs names it the same way.
|
||||
const ASSISTANT = "assistant";
|
||||
export const COPY_STATES = Object.freeze(["instance", "unbound", "refused"]);
|
||||
|
||||
// The bus instance name of a copy. A root copy keeps its name. A project agent is
|
||||
// `<name>.<project>`, so two projects may define the same name and a project may define a name an
|
||||
// organization or root agent already has, each as its own instance. Null when it isn't a valid id.
|
||||
export function copyInstanceName(name, project = null) {
|
||||
const instance = project === null ? name : `${name}.${project}`;
|
||||
return ID.test(instance) ? instance : null;
|
||||
}
|
||||
|
||||
// The credential services a copy of `definition` would need bound in the organization, and whether
|
||||
// it needs a tracker bot. The host key comes from a grant record at bus start, never from the file,
|
||||
// so it is not one (organization-config.mjs, checkRoles).
|
||||
function needs(definition, rolesDir, cache) {
|
||||
if (!cache.has(definition)) {
|
||||
const role = loadRole(rolesDir, definition);
|
||||
const services = role.credentials.map((c) => c.service).filter((s) => s !== HOST_SERVICE);
|
||||
cache.set(definition, [...new Set(services)].sort());
|
||||
}
|
||||
return cache.get(definition);
|
||||
}
|
||||
|
||||
// The copies of one organization, from its raw document (as its own writer read it) and the root
|
||||
// agents loadRootAgents returned. `root` is null when the root file refused: the root level is then
|
||||
// hidden everywhere (design section 6), so no root copy is made, and project agents still are.
|
||||
// Returns a frozen plain-data snapshot { org, root, rootAgents, copies: { instance: copy } } where
|
||||
// each copy is { name, level, project, definition, vars, state, missing?, reason? }. `rootAgents`
|
||||
// holds the root agents this organization takes (not excluded, not the root assistant), so a later
|
||||
// read of the organization file recomputes each project's union without reading the root file.
|
||||
export function materializeCopies({ orgId, doc, root, rolesDir }) {
|
||||
const rootState = root === null ? "refused" : root.state;
|
||||
const levels = resolveAgentLevels({ orgId, doc, root: root ?? { agents: {} }, rolesDir });
|
||||
const rootAgents = {};
|
||||
for (const [name, entry] of Object.entries(root?.agents ?? {})) {
|
||||
if (entry.definition === ASSISTANT || entry.excludeOrgs.includes(orgId)) continue;
|
||||
rootAgents[name] = { definition: entry.definition, vars: entry.vars ?? {}, excludeOrgs: [] };
|
||||
}
|
||||
const cache = new Map();
|
||||
const copies = {};
|
||||
const add = (agent, project, refusal = null) => {
|
||||
const instance = copyInstanceName(agent.name, project);
|
||||
// An over-long project agent name has no instance; it refuses under its own name.
|
||||
const key = instance ?? `${agent.name}.${project}`;
|
||||
const base = { name: agent.name, level: project === null ? "root" : "project", project, definition: agent.definition, vars: agent.vars };
|
||||
// Two agents whose instance names come out the same (root checker.stack and project stack's
|
||||
// checker, or two dotted names) refuse both, naming each; neither overwrites the other. The
|
||||
// entry lists every claimant, so a reader finds each agent's copy by its name and project.
|
||||
if (Object.hasOwn(copies, key)) {
|
||||
const claimants = [...(copies[key].claims ?? [{ name: copies[key].name, project: copies[key].project }]), { name: agent.name, project }];
|
||||
copies[key] = { ...copies[key], state: "refused", reason: collision(key, claimants), claims: claimants };
|
||||
delete copies[key].missing;
|
||||
return;
|
||||
}
|
||||
// The organization file's own instance of that name wins: no copy is made (design section 3).
|
||||
if (instance !== null && Object.hasOwn(doc?.roles ?? {}, instance)) return;
|
||||
if (refusal) { copies[key] = { ...base, state: "refused", reason: refusal }; return; }
|
||||
if (instance === null) { copies[key] = { ...base, state: "refused", reason: `project ${project}: ${agent.name}.${project} is longer than an instance name may be` }; return; }
|
||||
const missing = needs(agent.definition, rolesDir, cache);
|
||||
copies[key] = missing.length ? { ...base, state: "unbound", missing } : { ...base, state: "instance" };
|
||||
};
|
||||
// Root copies: the base union's root-level agents. It already leaves out the root assistant (A6),
|
||||
// the ones this organization is excluded from and the ones an org instance shadows by name.
|
||||
for (const agent of levels.base.agents) if (agent.level === "root") add(agent, null);
|
||||
// Project agents: each project's own define entries. When the project's union refuses, every
|
||||
// agent it defines refuses too, naming the project and the reason.
|
||||
for (const [project, entries] of Object.entries(levels.projects)) {
|
||||
const union = levels.unions[project];
|
||||
for (const [name, entry] of Object.entries(entries)) {
|
||||
if (entry.form !== "define") continue;
|
||||
const agent = { name, definition: entry.definition, vars: entry.vars };
|
||||
add(agent, project, union.ok ? null : `project ${project} refuses its agents: ${union.reason}`);
|
||||
}
|
||||
}
|
||||
return deepFreeze({ org: orgId, root: rootState, rootAgents, copies });
|
||||
}
|
||||
|
||||
// The view of a snapshot that leaves the boot: names, levels, definitions, states, the missing
|
||||
// service names and refusal reasons. No vars.
|
||||
export function copiesView(copies) {
|
||||
return Object.fromEntries(Object.entries(copies ?? {}).map(([instance, c]) => [instance, {
|
||||
name: c.name, level: c.level, project: c.project, definition: c.definition, state: c.state,
|
||||
...(c.missing ? { missing: [...c.missing] } : {}), ...(c.reason ? { reason: c.reason } : {}),
|
||||
...(c.claims ? { claims: c.claims.map(({ name, project }) => ({ name, project })) } : {}),
|
||||
}]));
|
||||
}
|
||||
|
||||
// Every string the document or the copy holds as a var or a credential reference, longest first.
|
||||
function valuesOf(document, copy) {
|
||||
const out = new Set();
|
||||
const walk = (x) => {
|
||||
if (typeof x === "string") { if (x.length >= 3) out.add(x); }
|
||||
else if (x && typeof x === "object") for (const v of Object.values(x)) walk(v);
|
||||
};
|
||||
walk(copy.vars);
|
||||
walk(document.vars);
|
||||
for (const role of Object.values(document.roles ?? {})) { walk(role?.vars); walk(role?.credentials); }
|
||||
for (const project of Object.values(document.projects ?? {})) walk(project?.vars);
|
||||
return [...out].sort((a, b) => b.length - a.length);
|
||||
}
|
||||
|
||||
// A validation refusal for a copy, said without the host path, a quoted "got" value, or any string
|
||||
// the document or the copy holds as a var or a credential reference.
|
||||
function reasonOf(error, file, orgId, document, copy) {
|
||||
let message = error.message.replaceAll(file, `${orgId}.json`).replace(/; got [^)]*\)/g, ")").replace(/ \(got [\s\S]*$/, "");
|
||||
for (const value of valuesOf(document, copy)) message = message.replaceAll(value, "<value>");
|
||||
return message.slice(0, 400);
|
||||
}
|
||||
|
||||
const entryOf = (copy, vars = copy.vars) => ({ definition: copy.definition, ...(Object.keys(vars ?? {}).length ? { vars: structuredClone(vars) } : {}) });
|
||||
|
||||
// Whether any code project of the raw document has its own agent entries.
|
||||
const hasProjectAgents = (document) => Object.values(document?.projects ?? {}).some((p) => p?.kind !== "library" && p?.agents !== undefined);
|
||||
|
||||
// `vars` narrowed by each project entry in `narrows`, the way projectUnion narrows one: mergeVars
|
||||
// applies the intersect rule, and only the keys either side set are kept.
|
||||
function narrowVars(level, vars, narrows) {
|
||||
return narrows.reduce((current, entry) => {
|
||||
const merged = mergeVars([{ source: level, vars: current }, { source: "project", vars: entry }]).vars;
|
||||
const keys = new Set([...Object.keys(current), ...Object.keys(entry)]);
|
||||
return Object.fromEntries(Object.entries(merged).filter(([key]) => keys.has(key)));
|
||||
}, vars ?? {});
|
||||
}
|
||||
|
||||
// The instance name each union agent runs as: a project's own agent is its copy, every other agent
|
||||
// is the instance of its own name.
|
||||
const unionInstance = (agent, project) => (agent.level === "project" ? copyInstanceName(agent.name, project) ?? `${agent.name}.${project}` : agent.name);
|
||||
|
||||
// Why a project copy can't join this document, or null when it can: its project is still a declared
|
||||
// code project, the project still defines it with the definition and vars the bus started with, and
|
||||
// the project's union, recomputed from this document, still accepts it. A copy is made at boot, so
|
||||
// a changed definition or vars waits for the next start; until then the copy refuses rather than
|
||||
// launch with settings the project no longer gives it.
|
||||
function projectCopyRefusal(copy, document, levels) {
|
||||
const p = document.projects?.[copy.project];
|
||||
if (!p || p.kind === "library") return `project ${copy.project} is no longer declared`;
|
||||
const union = levels.unions[copy.project];
|
||||
if (!union.ok) return `project ${copy.project} refuses its agents: ${union.reason}`;
|
||||
const entry = levels.projects[copy.project]?.[copy.name];
|
||||
if (entry?.form !== "define" || entry.definition !== copy.definition) return `project ${copy.project} no longer defines ${copy.name} as ${copy.definition}`;
|
||||
if (!isDeepStrictEqual(entry.vars ?? {}, copy.vars ?? {})) return `project ${copy.project} changed ${copy.name}'s vars since the bus started; the change takes effect at the next start`;
|
||||
return null;
|
||||
}
|
||||
|
||||
// The organization document with the snapshot's bound copies added to `roles`, validated whole by
|
||||
// validateBusinessDocument, the loader every org instance goes through. Each project's union is
|
||||
// recomputed from this document and the snapshot's root agents (never the root file), so:
|
||||
// - the business carries `unions`: for each code project with agents, { ok: true, agents: [the
|
||||
// instance names available there] } or { ok: false, reason };
|
||||
// - an instance a project narrows is narrowed in `roles` itself, so its tools, network and actions
|
||||
// are the intersection over every project that narrows it (a narrow in one project narrows the
|
||||
// instance in every launch: fail closed, since one launch mounts every project it may work in);
|
||||
// - a project copy whose project stops defining it, or whose union now refuses, is refused.
|
||||
// A copy the document refuses (a model var the organization doesn't list, say) is left out and
|
||||
// reported refused; the rest still join. A copy whose name the organization file now declares is
|
||||
// dropped: the file's own instance wins. Returns a frozen business whose `copies` key is the
|
||||
// snapshot's view, with each copy's state as it is in this document; a business with no copies and
|
||||
// no project agents is plain validateBusinessDocument.
|
||||
export function withCopies(document, file, { rolesDir, snapshot }) {
|
||||
const copies = snapshot?.copies ?? {};
|
||||
if (Object.keys(copies).length === 0 && !hasProjectAgents(document)) return validateBusinessDocument(document, file, { rolesDir });
|
||||
// The document's own refusals come first, whole.
|
||||
validateBusinessDocument(document, file, { rolesDir });
|
||||
const orgId = snapshot?.org ?? document.id;
|
||||
const levels = resolveAgentLevels({ orgId, doc: document, root: { agents: snapshot?.rootAgents ?? {} }, rolesDir });
|
||||
const unions = {};
|
||||
const narrows = {};
|
||||
for (const [project, union] of Object.entries(levels.unions)) {
|
||||
if (!union.ok) { unions[project] = { ok: false, reason: union.reason }; continue; }
|
||||
unions[project] = { ok: true, agents: union.agents.map((a) => unionInstance(a, project)).sort() };
|
||||
for (const agent of union.agents) {
|
||||
if (agent.level !== "project" && agent.narrowed.length) (narrows[agent.name] ??= []).push(levels.projects[project][agent.name].vars);
|
||||
}
|
||||
}
|
||||
const roles = { ...document.roles };
|
||||
for (const [name, list] of Object.entries(narrows)) {
|
||||
if (Object.hasOwn(roles, name)) roles[name] = { ...roles[name], vars: narrowVars("org", roles[name].vars, list) };
|
||||
}
|
||||
const narrowed = { ...document, roles };
|
||||
const view = copiesView(copies);
|
||||
// Who claims each copy's instance name now: the boot's root copies and the projects' define
|
||||
// entries as this document has them. A project that comes to define an agent whose name collides
|
||||
// with a copy refuses that copy, naming every claimant, as the boot does.
|
||||
const claimants = {};
|
||||
const claim = (key, who) => { if (!(claimants[key] ??= []).some((c) => c.name === who.name && c.project === who.project)) claimants[key].push(who); };
|
||||
for (const [instance, copy] of Object.entries(copies)) for (const c of copy.claims ?? [{ name: copy.name, project: copy.project }]) if (c.project === null) claim(instance, c);
|
||||
for (const [project, entries] of Object.entries(levels.projects)) {
|
||||
for (const [name, entry] of Object.entries(entries)) if (entry.form === "define") claim(copyInstanceName(name, project) ?? `${name}.${project}`, { name, project });
|
||||
}
|
||||
const join = {};
|
||||
for (const [instance, copy] of Object.entries(copies)) {
|
||||
// The organization file's own instance of this name wins; the copy is gone, not refused.
|
||||
if (Object.hasOwn(roles, instance)) { delete view[instance]; continue; }
|
||||
if ((claimants[instance]?.length ?? 0) > 1) {
|
||||
const all = claimants[instance].map(({ name, project }) => ({ name, project }));
|
||||
const { missing: _, ...rest } = view[instance];
|
||||
view[instance] = { ...rest, state: "refused", reason: collision(instance, all), claims: all };
|
||||
continue;
|
||||
}
|
||||
if (copy.state !== "instance") continue;
|
||||
const refused = copy.project === null ? null : projectCopyRefusal(copy, document, levels);
|
||||
if (refused) { view[instance] = { ...view[instance], state: "refused", reason: refused }; continue; }
|
||||
const entry = entryOf(copy, copy.project === null && narrows[copy.name] ? narrowVars("root", copy.vars, narrows[copy.name]) : copy.vars);
|
||||
try {
|
||||
validateBusinessDocument({ ...narrowed, roles: { ...roles, [instance]: entry } }, file, { rolesDir });
|
||||
join[instance] = entry;
|
||||
} catch (error) {
|
||||
if (error?.name !== "BusinessError") throw error;
|
||||
view[instance] = { ...view[instance], state: "refused", reason: reasonOf(error, file, orgId, document, copy) };
|
||||
}
|
||||
}
|
||||
const business = validateBusinessDocument({ ...narrowed, roles: { ...roles, ...join } }, file, { rolesDir });
|
||||
return deepFreeze({ ...business, ...(Object.keys(copies).length ? { copies: view } : {}), unions });
|
||||
}
|
||||
|
||||
// The organization with its copies, as a bus or launcher loads it. The plain load keeps
|
||||
// loadBusiness's file checks; when there are copies or project agents, the document is read again
|
||||
// through the organization's own writer and validated with them, and only that second read is used.
|
||||
export function loadWithCopies(id, { dir, rolesDir, snapshot }) {
|
||||
const plain = loadBusiness(id, { dir, rolesDir });
|
||||
const { document } = organizationWriter({ dir, rolesDir }).read(id);
|
||||
if ((!snapshot || Object.keys(snapshot.copies ?? {}).length === 0) && !hasProjectAgents(document)) return plain;
|
||||
return withCopies(document, businessFilePath(id, dir), { rolesDir, snapshot });
|
||||
}
|
||||
|
||||
// Why `instance` can't work in code project `project` of this business, as { code, message }, or
|
||||
// null when it can. A project copy works only in its own project; an instance a project excludes,
|
||||
// or any instance in a project whose union refuses, doesn't work there. The message names the
|
||||
// instance, the project and the reason, never a value.
|
||||
export function projectRefusal(business, instance, project) {
|
||||
const copy = copyOf(business, instance);
|
||||
if (copy?.project != null && copy.project !== project) {
|
||||
return { code: "project-agent-elsewhere", message: `${instance} works only in project ${copy.project}` };
|
||||
}
|
||||
const union = project == null ? undefined : business?.unions?.[project];
|
||||
if (!union) return null;
|
||||
if (!union.ok) return { code: "project-refused", message: `${instance} can't work in project ${project}: project ${project} refuses its agents: ${union.reason}` };
|
||||
if (!union.agents.includes(instance)) return { code: "project-refused", message: `${instance} can't work in project ${project}: the project excludes it` };
|
||||
return null;
|
||||
}
|
||||
|
||||
// The copy a launch names, or null for an org instance. `business` is one withCopies returned.
|
||||
export function copyOf(business, instance) {
|
||||
return business?.copies && Object.hasOwn(business.copies, instance) ? business.copies[instance] : null;
|
||||
}
|
||||
|
||||
// Why a copy can't launch, as { code, message }, or null when it can. The message names the agent,
|
||||
// its project and the missing services or the refusal, and never a value.
|
||||
export function copyRefusal(business, instance) {
|
||||
const copy = copyOf(business, instance);
|
||||
if (!copy || copy.state === "instance") return null;
|
||||
if (copy.state === "unbound") {
|
||||
return { code: "credential-unbound", message: `${instance} has no binding in ${business.id} for ${copy.missing.join(", ")}` };
|
||||
}
|
||||
return { code: copy.project === null ? "copy-refused" : "project-refused", message: `${instance} can't launch in ${business.id}: ${copy.reason}` };
|
||||
}
|
||||
@@ -18,10 +18,10 @@ export function checkAppearance(value, where) {
|
||||
requireObject(value, where);
|
||||
rejectUnknownKeys(value, ["theme", "mode"], where);
|
||||
if (Object.hasOwn(value, "theme") && (typeof value.theme !== "string" || value.theme.length > 64 || !NAME.test(value.theme))) {
|
||||
refuse(`${where}.theme must be an installed theme's name, matching ${NAME} (got ${JSON.stringify(value.theme)})`);
|
||||
refuse(`${where}.theme must be an installed theme's name, matching ${NAME}`);
|
||||
}
|
||||
if (Object.hasOwn(value, "mode") && !APPEARANCE_MODES.includes(value.mode)) {
|
||||
refuse(`${where}.mode must be one of ${APPEARANCE_MODES.join(", ")} (got ${JSON.stringify(value.mode)})`);
|
||||
refuse(`${where}.mode must be one of ${APPEARANCE_MODES.join(", ")}`);
|
||||
}
|
||||
return Object.freeze({
|
||||
...(Object.hasOwn(value, "theme") ? { theme: value.theme } : {}),
|
||||
|
||||
@@ -67,7 +67,20 @@ export function runConfigCommand(argv, { stdout = process.stdout, stderr = proce
|
||||
maxBuffer: 1024 * 1024,
|
||||
env,
|
||||
});
|
||||
if (proc.status !== 0) fail(3, `system config problem (mosaic-config exit ${proc.status}): ${proc.stderr.trim()}`);
|
||||
// Render known key paths with fixed constraints; never relay child stderr.
|
||||
if (proc.status !== 0) {
|
||||
const problem = proc.stderr.trim();
|
||||
const field = problem.startsWith('mosaic-config: execution "dataRoot"') ? "dataRoot"
|
||||
: /^mosaic-config: (?:unsupported )?"?((?:configVersion|environment|dataRoot|execution|console|federation|compactionReadBytes|compactionStreams)(?:\.[A-Za-z][A-Za-z0-9]*|\[\d+\])*)"?(?=[: ])/u.exec(problem)?.[1];
|
||||
const name = "a 1-200 character name starting with a letter or digit and containing only letters, digits, . _ : / -";
|
||||
const expected = { configVersion: "1", environment: "development or production", dataRoot: "a canonical absolute instance data path",
|
||||
"execution.backend": "docker", "execution.adapter": "pi or mock", "execution.provider": name, "execution.model": name };
|
||||
const detail = problem.startsWith("mosaic-config: configuration is not valid JSON (")
|
||||
? "configuration is not valid JSON; expected readable JSON: "
|
||||
: field ? `${field}: expected ${expected[field] ?? "a value satisfying the system configuration schema"}: `
|
||||
: "expected valid system configuration: ";
|
||||
fail(3, `system config problem (mosaic-config exit ${proc.status}): ${detail}${env.MOSAIC_CONFIG || join(env.HOME || "~", ".config", "mosaic-dev", "config.json")}${proc.error?.code ? ` (${proc.error.code})` : ""}`);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(proc.stdout);
|
||||
} catch {
|
||||
@@ -106,7 +119,7 @@ export function runConfigCommand(argv, { stdout = process.stdout, stderr = proce
|
||||
continue;
|
||||
}
|
||||
const project = loadProject(entry.root);
|
||||
if (project.id !== id) fail(2, `project file ${file} has id ${project.id}, but business ${organization.id} declares it as ${id}`);
|
||||
if (project.id !== id) fail(2, `project file ${file} has id that does not match the declared project key`);
|
||||
for (const instance of Object.keys(organization.roles)) resolveInstance({ system, business: organization, project, instance });
|
||||
projects[id] = "valid";
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export function validateProjectDocument(document, file) {
|
||||
}
|
||||
|
||||
export function loadProject(root) {
|
||||
if (typeof root !== "string" || !isAbsolute(root) || normalize(root) !== root) refuse(`project root must be a normalized absolute path (got ${JSON.stringify(root)})`);
|
||||
if (typeof root !== "string" || !isAbsolute(root) || normalize(root) !== root) refuse(`project root must be a normalized absolute path`);
|
||||
const file = projectFilePath(root);
|
||||
return validateProjectDocument(readJsonFile(file, "project file"), file);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Caller-only descriptions, shared by launch and live bus reads. Inputs are validated policy,
|
||||
// never credential references, vars, call arguments or file contents.
|
||||
import { GATED_ONLY } from './vocabulary.mjs';
|
||||
|
||||
const DOMAINS = Object.freeze({ 'git.merge.protected': 'technical', 'policy.change': 'delivery' });
|
||||
|
||||
// TI extension point for AU: today's fixed levels, without changing any routing.
|
||||
export function decisionAutonomy(decisionClass) {
|
||||
return ['cross-role', 'merge', 'policy'].includes(decisionClass) ? 'out of the loop' : 'in the loop';
|
||||
}
|
||||
|
||||
export function ownRails({ instance, authority, network, credentials = [], messaging = null, arbiters = {}, launcher = null }) {
|
||||
const actions = [];
|
||||
for (const [kind, held] of Object.entries(authority ?? {})) {
|
||||
for (const action of held) {
|
||||
const chiefOnly = ['role.hire', 'role.fire'].includes(action) && launcher !== instance;
|
||||
const hard = GATED_ONLY.includes(action) || (action === 'role.launch' && launcher !== instance);
|
||||
const domains = DOMAINS[action] ? [DOMAINS[action]] : ['delivery', 'technical'];
|
||||
const routes = chiefOnly ? [{ to: 'unavailable' }] : hard ? [{ to: 'human' }] : kind === 'withinRole' ? [{ to: 'alone' }]
|
||||
: domains.map(domain => ({ domain, to: arbiters[domain] === instance ? 'human' : arbiters[domain] ?? 'unconfigured' }));
|
||||
actions.push({ action, routes });
|
||||
}
|
||||
}
|
||||
const resolve = [...(authority?.withinRole ?? []), ...(authority?.crossRole ?? [])]
|
||||
.filter(action => action.startsWith('decision.resolve.')).map(right => {
|
||||
const domain = right.slice('decision.resolve.'.length);
|
||||
const classes = domain === 'technical' ? ['cross-role', 'merge'] : domain === 'delivery' ? ['cross-role', 'policy'] : [];
|
||||
return { right, classes: classes.map(name => ({ name, level: decisionAutonomy(name) })) };
|
||||
});
|
||||
return {
|
||||
actions, resolve, network: network ?? 'unknown',
|
||||
credentials: credentials.map(({ service, scopes }) => ({ service, ...(scopes === undefined ? {} : { scopes: structuredClone(scopes) }) })),
|
||||
messaging: messaging ? { sendTo: [...messaging.sendTo], receiveFrom: [...messaging.receiveFrom] } : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function railsText(rails) {
|
||||
const lines = ['## Your rails', '', 'These are your resolved rails. Call my_rails for the current rails after a narrowing.'];
|
||||
for (const { action, routes } of rails.actions) {
|
||||
const route = routes.map(({ domain, to }) => `${domain ? `${domain}${domain === 'delivery' && routes.length > 1 ? ' (default)' : ''}: ` : ''}${to === 'human' ? 'a person' : to === 'alone' ? 'alone' : to === 'unavailable' ? 'unavailable here; only the configured chief can hire or fire' : to === 'unconfigured' ? 'unconfigured; report missing arbiter' : `arbiter instance ${JSON.stringify(to)}`}`).join('; ');
|
||||
lines.push(`- ${action}: ${route}.`);
|
||||
}
|
||||
if (!rails.actions.length) lines.push('- Held actions: none.');
|
||||
for (const { right, classes } of rails.resolve) lines.push(`- ${right} levels: ${classes.map(c => `${c.name}: ${c.level}`).join('; ') || 'unconfigured'}.`);
|
||||
lines.push('- A resolve right must be held withinRole, and the decision must route to you. PRD approval also follows the request mode and author checks.');
|
||||
lines.push('- Unheld actions are gated. Hard waits always go to a person; raise with blocking false when there is no task. A current spec citation can route other gated work to its arbiter.');
|
||||
lines.push('- Revoking a protected instance is a hard wait; ending it follows the chief/person protection checks. Messaging also checks the recipient\'s policy.');
|
||||
lines.push(`- Network: ${rails.network}.`);
|
||||
for (const credential of rails.credentials) lines.push(`- Credential service ${credential.service}; scopes: ${credential.scopes === undefined ? 'none declared' : JSON.stringify(credential.scopes)}.`);
|
||||
if (!rails.credentials.length) lines.push('- Credential services: none.');
|
||||
lines.push(`- Messaging sendTo: ${rails.messaging ? rails.messaging.sendTo.join(', ') || 'none' : 'unrestricted by your definition'}.`);
|
||||
lines.push(`- Messaging receiveFrom: ${rails.messaging ? rails.messaging.receiveFrom.join(', ') || 'none' : 'unrestricted by your definition'}.`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
// the record a launcher or the broker uses. Plain values: the most specific
|
||||
// layer wins. Limits: every layer narrows, nothing widens.
|
||||
|
||||
import { ownRails } from "./rails.mjs";
|
||||
import { refuse } from "./errors.mjs";
|
||||
import { ACTIONS, NETWORKS, isHostKind } from "./vocabulary.mjs";
|
||||
import { checkVars, mergeVars } from "./vars.mjs";
|
||||
@@ -89,6 +90,7 @@ export function resolveInstance({ system, business, project = null, instance })
|
||||
limits: { tools, network, authority: { withinRole, crossRole } },
|
||||
// Row 210 (#1707, design D5): a host key is used only by the bus child, never by the session,
|
||||
// so it stays out of the session's credentials and the harness's needs.
|
||||
rails: ownRails({ instance, authority: { withinRole, crossRole }, network, credentials: definition.credentials, messaging: definition.messaging, arbiters: business.arbiters, launcher: business.launch?.by }),
|
||||
credentials: Object.fromEntries(Object.entries(entry.credentials).filter(([kind]) => !isHostKind(kind))),
|
||||
tracker: entry.tracker,
|
||||
launch,
|
||||
|
||||
@@ -24,7 +24,7 @@ const SKILL_NAME = /^[A-Za-z0-9._-]+$/;
|
||||
function checkTools(tools) {
|
||||
if (!Array.isArray(tools) || tools.length === 0) refuse('role "tools" must be a non-empty array of tool names');
|
||||
return requireDistinctList(tools, "role tools", (tool) => {
|
||||
if (!TOOLS.includes(tool)) refuse(`unsupported tool: ${JSON.stringify(tool)} (supported: ${TOOLS.join(", ")})`);
|
||||
if (!TOOLS.includes(tool)) refuse(`unsupported tool (supported: ${TOOLS.join(", ")})`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -39,12 +39,12 @@ function checkAuthority(authority) {
|
||||
const lists = {};
|
||||
for (const key of ["withinRole", "crossRole"]) {
|
||||
lists[key] = requireDistinctList(authority[key], `role authority.${key}`, (action) => {
|
||||
if (!ACTIONS.includes(action)) refuse(`unknown action in authority.${key}: ${JSON.stringify(action)}`);
|
||||
if (GATED_ONLY.includes(action)) refuse(`authority.${key} lists ${action}, which is always gated`);
|
||||
if (!ACTIONS.includes(action)) refuse(`unknown action in authority.${key}; expected an action from the role vocabulary`);
|
||||
if (GATED_ONLY.includes(action)) refuse(`authority.${key} lists an action which is always gated`);
|
||||
});
|
||||
}
|
||||
const both = lists.withinRole.filter((a) => lists.crossRole.includes(a));
|
||||
if (both.length > 0) refuse(`action listed as both withinRole and crossRole: ${both.join(", ")}`);
|
||||
if (both.length > 0) refuse(`action listed as both withinRole and crossRole`);
|
||||
return lists;
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ function checkMessaging(messaging) {
|
||||
function checkSkills(skills) {
|
||||
return requireDistinctList(skills, "role skills", (name) => {
|
||||
if (typeof name !== "string" || !SKILL_NAME.test(name) || name === "." || name === "..") {
|
||||
refuse(`role skill must be a skill directory name matching ${SKILL_NAME} (got ${JSON.stringify(name)})`);
|
||||
refuse(`role skill must be a skill directory name matching ${SKILL_NAME}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -87,13 +87,13 @@ function checkHireable(hireable, file) {
|
||||
const dir = dirname(file);
|
||||
return requireDistinctList(hireable, "role hireable", (name) => {
|
||||
requireId(name, "role hireable entry");
|
||||
if (CREATIVE_DEFINITIONS.includes(name)) refuse(`hireable names ${name}, a creative definition only the human adds`);
|
||||
if (CREATIVE_DEFINITIONS.includes(name)) refuse(`hireable names a creative definition only the human adds`);
|
||||
const path = join(dir, `${name}.json`);
|
||||
if (!existsSync(path)) refuse(`hireable names ${name}, which has no role file`);
|
||||
if (!existsSync(path)) refuse(`hireable names a definition which has no role file`);
|
||||
const named = validateRoleDocument(readJsonFile(path, "role file"), path, { hireable: false });
|
||||
if (named.roleVersion !== 2) refuse(`hireable names ${name}, which is role version 1`);
|
||||
if (named.roleVersion !== 2) refuse(`hireable names a definition which is role version 1`);
|
||||
const outside = unhireableAction(named);
|
||||
if (outside !== null) refuse(`hireable names ${name}, which holds ${outside}`);
|
||||
if (outside !== null) refuse(`hireable names a definition which holds an action outside the hireable ceiling`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -101,12 +101,12 @@ function checkGiteaScopes(scopes) {
|
||||
const list = requireDistinctList(scopes, "gitea scopes", (scope) => {
|
||||
const m = typeof scope === "string" ? /^(read|write):([a-z]+)$/.exec(scope) : null;
|
||||
if (!m || !GITEA_SCOPE_CATEGORIES.includes(m[2])) {
|
||||
refuse(`unsupported gitea scope: ${JSON.stringify(scope)} (expected read:<category> or write:<category>; categories: ${GITEA_SCOPE_CATEGORIES.join(", ")})`);
|
||||
refuse(`unsupported gitea scope (expected read:<category> or write:<category>; categories: ${GITEA_SCOPE_CATEGORIES.join(", ")})`);
|
||||
}
|
||||
}, { nonEmpty: true });
|
||||
const categories = list.map((s) => s.split(":")[1]);
|
||||
const twice = categories.filter((c, i) => categories.indexOf(c) !== i);
|
||||
if (twice.length > 0) refuse(`gitea scopes name ${twice[0]} twice; a token holds one level per category`);
|
||||
if (twice.length > 0) refuse(`gitea scopes name a category twice; a token holds one level per category`);
|
||||
return list;
|
||||
}
|
||||
|
||||
@@ -116,9 +116,9 @@ function checkVikunjaScopes(scopes) {
|
||||
const out = {};
|
||||
for (const [group, verbs] of Object.entries(scopes)) {
|
||||
const allowed = VIKUNJA_GRANTABLE[group];
|
||||
if (!allowed) refuse(`vikunja route group not grantable to a role: ${JSON.stringify(group)}`);
|
||||
if (!allowed) refuse(`vikunja route group not grantable to a role`);
|
||||
out[group] = requireDistinctList(verbs, `vikunja scopes.${group}`, (verb) => {
|
||||
if (!allowed.includes(verb)) refuse(`vikunja verb not grantable to a role: ${group}.${JSON.stringify(verb)}`);
|
||||
if (!allowed.includes(verb)) refuse(`vikunja verb not grantable to a role`);
|
||||
}, { nonEmpty: true });
|
||||
}
|
||||
return out;
|
||||
@@ -135,7 +135,7 @@ function checkCredentials(credentials) {
|
||||
if (!host && !SERVICES.includes(entry.service)) {
|
||||
refuse(`role credentials[${i}].service must be one of: ${[...SERVICES, HOST_SERVICE].join(", ")}`);
|
||||
}
|
||||
if (seen.has(entry.service)) refuse(`role credentials name ${entry.service} twice`);
|
||||
if (seen.has(entry.service)) refuse(`role credentials name a service twice`);
|
||||
seen.add(entry.service);
|
||||
if (host) return { service: HOST_SERVICE };
|
||||
const scopes = entry.service === "gitea" ? checkGiteaScopes(entry.scopes) : checkVikunjaScopes(entry.scopes);
|
||||
@@ -145,7 +145,7 @@ function checkCredentials(credentials) {
|
||||
|
||||
function checkContract(contract, file) {
|
||||
if (typeof contract !== "string" || !CONTRACT_NAME.test(contract)) {
|
||||
refuse(`role "contract" must be a Markdown file name in the role's directory, matching ${CONTRACT_NAME} (got ${JSON.stringify(contract)})`);
|
||||
refuse(`role "contract" must be a Markdown file name in the role's directory, matching ${CONTRACT_NAME}`);
|
||||
}
|
||||
const path = join(dirname(file), contract);
|
||||
let stat;
|
||||
@@ -167,12 +167,12 @@ function checkContract(contract, file) {
|
||||
// docs: its authority is withinRole CREATIVE_ACTIONS at most, and its one credential is gitea.
|
||||
function checkCreative(role) {
|
||||
const wider = role.tools.filter((tool) => !CREATIVE_TOOLS.includes(tool));
|
||||
if (wider.length > 0) refuse(`creative role ${role.name} may not name tools ${wider.join(", ")} (allowed: ${CREATIVE_TOOLS.join(", ")})`);
|
||||
if (wider.length > 0) refuse(`creative role ${role.name} may not name tools outside the allowed set (allowed: ${CREATIVE_TOOLS.join(", ")})`);
|
||||
if (role.authority.crossRole.length > 0 || role.authority.withinRole.some((a) => !CREATIVE_ACTIONS.includes(a))) {
|
||||
refuse(`creative role ${role.name} may hold only withinRole ${CREATIVE_ACTIONS.join(" and ")}`);
|
||||
}
|
||||
const services = role.credentials.map((c) => c.service).filter((service) => service !== CREATIVE_SERVICE);
|
||||
if (services.length > 0) refuse(`creative role ${role.name} may hold only the ${CREATIVE_SERVICE} credential (got ${services.join(", ")})`);
|
||||
if (services.length > 0) refuse(`creative role ${role.name} may hold only the ${CREATIVE_SERVICE} credential`);
|
||||
if (role.messaging === null) refuse(`creative role ${role.name} requires "messaging"`);
|
||||
for (const key of ["sendTo", "receiveFrom"]) {
|
||||
if (role.messaging[key].some((entry) => entry !== "human")) {
|
||||
@@ -193,7 +193,7 @@ export function validateRoleDocument(document, file, options = {}) {
|
||||
rejectUnknownKeys(document, document.roleVersion === 1 ? V1_KEYS : V2_KEYS, "role");
|
||||
requireId(document.name, "role name");
|
||||
const base = basename(file).replace(/\.json$/, "");
|
||||
if (document.name !== base) refuse(`role "name" (${document.name}) must match its filename (${base}.json)`);
|
||||
if (document.name !== base) refuse(`role "name" must match its filename (${base}.json)`);
|
||||
const tools = checkTools(document.tools);
|
||||
if (document.roleVersion === 1) {
|
||||
if (CREATIVE_DEFINITIONS.includes(document.name)) refuse(`creative role ${document.name} must be version 2`);
|
||||
|
||||
@@ -44,7 +44,7 @@ export function requireDistinctList(value, where, check, { nonEmpty = false } =
|
||||
const seen = new Set();
|
||||
for (const item of value) {
|
||||
check(item);
|
||||
if (seen.has(item)) refuse(`duplicate entry in ${where}: ${JSON.stringify(item)}`);
|
||||
if (seen.has(item)) refuse(`duplicate entry in ${where}`);
|
||||
seen.add(item);
|
||||
}
|
||||
return [...seen];
|
||||
|
||||
@@ -295,7 +295,7 @@ export const GRANT_ALL = "*";
|
||||
// and a viewer grant never covers it. An expired grant covers nothing.
|
||||
export function covers(grant, { business, project, instance }, now, { level } = {}) {
|
||||
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError("covers needs a valid Date for now");
|
||||
if (!LEVELS.includes(level)) throw new TypeError(`covers needs a level, one of ${LEVELS.join(", ")} (got ${JSON.stringify(level)})`);
|
||||
if (!LEVELS.includes(level)) throw new TypeError(`covers needs a level, one of ${LEVELS.join(", ")}`);
|
||||
if (grant.expires !== undefined && now.getTime() >= Date.parse(grant.expires)) return false;
|
||||
if (grant.business !== business) return false;
|
||||
if (!(grant.projects[0] === GRANT_ALL || grant.projects.includes(project))) return false;
|
||||
|
||||
@@ -104,19 +104,19 @@ test("users: unknown keys and the id rule", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].role = "x"; }), context()), /unsupported users\[1\] key: "role"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0] = "bean"; }), context()), /users\[0\] must be a JSON object/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "Bean"; }), context()), /users\[0\]\.id must match/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "bean"; }), context()), /users\[1\]\.id is a duplicate user id: bean/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "bean"; }), context()), /users\[1\]\.id is a duplicate user id/);
|
||||
});
|
||||
|
||||
test("users: human, tester and any business's human are refused", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "human"; d.grants = []; }), context()), /users\[0\]\.id must not be "human"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "tester"; d.grants = []; }), context()), /users\[1\]\.id must not be "tester"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "melanie"; d.grants = []; }), context()), /users\[0\]\.id must not be a business's human \("melanie"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "human"; d.grants = []; }), context()), /users\[0\]\.id must not be a bus principal/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "tester"; d.grants = []; }), context()), /users\[1\]\.id must not be a bus principal/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "melanie"; d.grants = []; }), context()), /users\[0\]\.id must not be a business's human/);
|
||||
// The human of a business the grant doesn't name is refused too.
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "jason"; d.grants = []; }), context()), /users\[1\]\.id must not be a business's human \("jason"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "jason"; d.grants = []; }), context()), /users\[1\]\.id must not be a business's human/);
|
||||
});
|
||||
|
||||
test("users: home is a given peer or local", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].home = "elsewhere"; }), context()), /users\[0\]\.home must be a configured peer or "local" \(got "elsewhere"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].home = "elsewhere"; }), context()), /users\[0\]\.home must be a configured peer or "local"/);
|
||||
refuses(() => checkAccess(mutated((d) => { delete d.users[1].home; }), context()), /users\[1\]\.home must be a configured peer/);
|
||||
refuses(() => checkAccess(doc(), context({ peers: [] })), /users\[0\]\.home must be a configured peer/);
|
||||
});
|
||||
@@ -125,7 +125,7 @@ test("users: subjects, provider, sub, uniqueness and owner subjects", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects = []; }), context()), /users\[0\]\.subjects must not be empty/);
|
||||
refuses(() => checkAccess(mutated((d) => { delete d.users[0].subjects; }), context()), /users\[0\]\.subjects must be an array/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].extra = 1; }), context()), /unsupported users\[0\]\.subjects\[0\] key: "extra"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].subjects[0].provider = "google"; }), context()), /users\[1\]\.subjects\[0\]\.provider must be a configured provider \(got "google"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].subjects[0].provider = "google"; }), context()), /users\[1\]\.subjects\[0\]\.provider must be a configured provider/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].sub = ""; }), context()), /users\[0\]\.subjects\[0\]\.sub must be a non-empty string/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].sub = 7; }), context()), /users\[0\]\.subjects\[0\]\.sub must be a non-empty string/);
|
||||
// A pair twice in one user, and across two users.
|
||||
@@ -140,15 +140,15 @@ test("users: subjects, provider, sub, uniqueness and owner subjects", () => {
|
||||
|
||||
test("grants: unknown keys, id, user and business", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].role = "x"; }), context()), /unsupported grants\[0\] key: "role"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].id = "g-1"; }), context()), /grants\[1\]\.id is a duplicate grant id: g-1/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].id = "g-1"; }), context()), /grants\[1\]\.id is a duplicate grant id/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].id = "G 1"; }), context()), /grants\[0\]\.id must match/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].user = "nobody"; }), context()), /grants\[0\]\.user must name a user in this file \(got "nobody"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].business = "other"; }), context()), /grants\[1\]\.business must name a declared business \(got "other"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].user = "nobody"; }), context()), /grants\[0\]\.user must name a user in this file/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].business = "other"; }), context()), /grants\[1\]\.business must name a declared business/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].business = "constructor"; }), context()), /grants\[1\]\.business must name a declared business/);
|
||||
});
|
||||
|
||||
test("grants: projects is [\"*\"] or distinct lowercase UUIDs", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["stack"]; }), context()), /grants\[1\]\.projects\[0\] must be "\*" or a bus project id, a lowercase UUID \(got "stack"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["stack"]; }), context()), /grants\[1\]\.projects\[0\] must be "\*" or a bus project id, a lowercase UUID/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1.toUpperCase()]; }), context()), /grants\[1\]\.projects\[0\] must be "\*" or a bus project id/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1, 7]; }), context()), /grants\[1\]\.projects\[1\] must be "\*" or a bus project id/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["*", P1]; }), context()), /grants\[1\]\.projects must be \["\*"\] alone/);
|
||||
@@ -172,7 +172,7 @@ test("grants: any level other than collaborator or viewer refuses, naming grants
|
||||
for (const bad of ["owner", "Collaborator", "COLLABORATOR", "Viewer", "", null, 1]) {
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].level = bad; }), context()), /grants\[1\]\.level must be one of collaborator, viewer/);
|
||||
}
|
||||
refuses(() => checkAccess(mutated((d) => { delete d.grants[0].level; }), context()), /grants\[0\]\.level must be one of collaborator, viewer \(got undefined\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { delete d.grants[0].level; }), context()), /grants\[0\]\.level must be one of collaborator, viewer/);
|
||||
});
|
||||
|
||||
test("grants: expires is optional ISO time, and a past expiry is not refused", () => {
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
// #1873 Phase A slice A3: root and project agent copies (design
|
||||
// docs/plans/2026-11-10_agent-levels-design.md sections 4 and 9 item 2).
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { checkRootAgents, loadRootAgents } from "../src/agent-levels.mjs";
|
||||
import {
|
||||
copiesView, copyInstanceName, copyRefusal, loadWithCopies, materializeCopies, projectRefusal, withCopies,
|
||||
} from "../src/agent-copies.mjs";
|
||||
import { businessFilePath, validateBusinessDocument } from "../src/organization-config.mjs";
|
||||
import { resolveInstance } from "../src/resolve.mjs";
|
||||
import { REPO, REPO_ROLES, businessDoc, systemFor, tmp, tokenFile, writeJson } from "./helpers.mjs";
|
||||
|
||||
const rolesDir = REPO_ROLES;
|
||||
const SECRET = "sk-test-NOT-A-SECRET-1234";
|
||||
const root = (agents) => ({ state: "ok", ...checkRootAgents({ agentsVersion: 1, agents }, { rolesDir }) });
|
||||
|
||||
// An organization `id` under `dir`, written where loadBusiness and the writer read it.
|
||||
function org(dir, id, edit = (d) => d) {
|
||||
const doc = edit(businessDoc(dir, id));
|
||||
writeJson(businessFilePath(id, dir), doc);
|
||||
return doc;
|
||||
}
|
||||
const boot = (dir, id, doc, r) => {
|
||||
const snapshot = materializeCopies({ orgId: id, doc, root: r, rolesDir });
|
||||
return { snapshot, business: withCopies(doc, businessFilePath(id, dir), { rolesDir, snapshot }) };
|
||||
};
|
||||
|
||||
test("A3: two root agents, one excluded: the other becomes an instance in each organization, each its own", () => {
|
||||
const dir = tmp();
|
||||
const r = root({ gate: { definition: "merge-gate" }, sec: { definition: "security-reviewer", excludeOrgs: ["acme"] }, assistant: { definition: "assistant" } });
|
||||
const acme = boot(dir, "acme", org(dir, "acme"), r);
|
||||
assert.deepEqual(Object.keys(acme.snapshot.copies), ["gate"]);
|
||||
assert.equal(acme.business.roles.gate.definition, "merge-gate");
|
||||
assert.equal(Object.hasOwn(acme.business.roles, "sec"), false, "the excluded root agent is no instance");
|
||||
// The root assistant stays shown, not launched (A6): never a copy.
|
||||
assert.equal(Object.hasOwn(acme.business.roles, "assistant"), false);
|
||||
assert.equal(acme.business.copies.gate.state, "instance");
|
||||
// A second organization makes its own copies from the same root agents.
|
||||
const beta = boot(dir, "beta", org(dir, "beta"), r);
|
||||
assert.deepEqual(Object.keys(beta.snapshot.copies).sort(), ["gate", "sec"]);
|
||||
assert.equal(beta.snapshot.org, "beta");
|
||||
assert.notEqual(beta.snapshot.copies.gate, acme.snapshot.copies.gate);
|
||||
assert.ok(Object.isFrozen(acme.snapshot.copies.gate), "the boot snapshot can't be changed in place");
|
||||
// Each copy carries only its own organization's entry: the credential map is the organization's
|
||||
// own, and a copy holds no credential.
|
||||
assert.deepEqual(acme.business.roles.gate.credentials, {});
|
||||
assert.deepEqual(beta.business.roles.gate.credentials, {});
|
||||
});
|
||||
|
||||
test("A3: an unbound copy refuses by name and binding, and no value reaches the refusal or the view", () => {
|
||||
const dir = tmp();
|
||||
const doc = org(dir, "acme", (d) => {
|
||||
// The secret is bound elsewhere in this organization: the coder's gitea token file is named it
|
||||
// and holds it.
|
||||
const file = tokenFile(join(dir, "secrets"), SECRET);
|
||||
writeFileSync(file, SECRET);
|
||||
d.roles.coder.credentials.gitea = { file, rotateBy: "2099-01-01" };
|
||||
return d;
|
||||
});
|
||||
const r = root({ researcher: { definition: "research" }, reviewer2: { definition: "reviewer" } });
|
||||
const { snapshot, business } = boot(dir, "acme", doc, r);
|
||||
assert.equal(snapshot.copies.researcher.state, "unbound");
|
||||
assert.deepEqual(snapshot.copies.researcher.missing, ["gitea"]);
|
||||
assert.deepEqual(snapshot.copies.reviewer2.missing, ["gitea", "vikunja"]);
|
||||
assert.equal(Object.hasOwn(business.roles, "researcher"), false, "an unbound copy is no instance");
|
||||
const refusal = copyRefusal(business, "researcher");
|
||||
assert.equal(refusal.code, "credential-unbound");
|
||||
assert.match(refusal.message, /researcher/);
|
||||
assert.match(refusal.message, /gitea/);
|
||||
assert.equal(copyRefusal(business, "coder"), null, "an org instance is no copy");
|
||||
for (const text of [refusal.message, copyRefusal(business, "reviewer2").message, JSON.stringify(business.copies), JSON.stringify(copiesView(snapshot.copies))]) {
|
||||
assert.equal(text.includes(SECRET), false);
|
||||
assert.equal(text.includes(dir), false, "no host path");
|
||||
}
|
||||
});
|
||||
|
||||
test("A3: a copy whose var the organization refuses is refused alone, and its value stays out of the reason", () => {
|
||||
const dir = tmp();
|
||||
const doc = org(dir, "acme");
|
||||
const r = root({ gate: { definition: "merge-gate" }, wide: { definition: "merge-gate", vars: { model: SECRET } } });
|
||||
const { business } = boot(dir, "acme", doc, r);
|
||||
assert.equal(business.roles.gate.definition, "merge-gate", "the other copy still joins");
|
||||
assert.equal(Object.hasOwn(business.roles, "wide"), false);
|
||||
assert.equal(business.copies.wide.state, "refused");
|
||||
const refusal = copyRefusal(business, "wide");
|
||||
assert.equal(refusal.code, "copy-refused");
|
||||
assert.match(refusal.message, /^wide can't launch in acme: /);
|
||||
assert.equal(refusal.message.includes(SECRET), false);
|
||||
assert.equal(JSON.stringify(business.copies).includes(SECRET), false);
|
||||
});
|
||||
|
||||
test("A3: a copy's tools and actions equal an org instance's of the same role; a copy that skips checkRoles fails this", () => {
|
||||
const dir = tmp();
|
||||
const doc = org(dir, "acme", (d) => ({ ...d, roles: { ...d.roles, "gate-org": { definition: "merge-gate", vars: { model: "glm-5.3-flash" } } } }));
|
||||
const r = root({ gate: { definition: "merge-gate", vars: { model: "glm-5.3-flash" } }, wide: { definition: "merge-gate", vars: { model: "not-a-listed-model" } } });
|
||||
const snapshot = materializeCopies({ orgId: "acme", doc, root: r, rolesDir });
|
||||
const file = businessFilePath("acme", dir);
|
||||
const system = systemFor(dir);
|
||||
const same = (business) => {
|
||||
assert.deepEqual(business.roles.gate, business.roles["gate-org"]);
|
||||
const copy = resolveInstance({ system, business, instance: "gate" });
|
||||
const own = resolveInstance({ system, business, instance: "gate-org" });
|
||||
assert.deepEqual(copy.limits.tools, own.limits.tools);
|
||||
assert.deepEqual(copy.limits.authority, own.limits.authority);
|
||||
assert.deepEqual(copy.credentials, own.credentials);
|
||||
// A copy the organization's own checks refuse never joins.
|
||||
assert.equal(Object.hasOwn(business.roles, "wide"), false);
|
||||
};
|
||||
same(withCopies(doc, file, { rolesDir, snapshot }));
|
||||
// The counterfactual: the copies joined as their raw entries, with no checkRoles.
|
||||
const plain = validateBusinessDocument(doc, file, { rolesDir });
|
||||
const skipped = { ...plain, roles: { ...plain.roles, gate: { definition: "merge-gate", vars: { model: "glm-5.3-flash" } }, wide: { definition: "merge-gate", vars: { model: "not-a-listed-model" } } } };
|
||||
assert.throws(() => same(skipped), assert.AssertionError);
|
||||
});
|
||||
|
||||
test("A3: a root file change after boot changes nothing until the next boot", () => {
|
||||
const dir = tmp();
|
||||
const doc = org(dir, "acme");
|
||||
writeJson(join(dir, "agents.json"), { agentsVersion: 1, agents: { gate: { definition: "merge-gate" } } });
|
||||
const snapshot = materializeCopies({ orgId: "acme", doc, root: loadRootAgents(dir, { rolesDir }), rolesDir });
|
||||
const first = loadWithCopies("acme", { dir, rolesDir, snapshot });
|
||||
assert.equal(first.roles.gate.definition, "merge-gate");
|
||||
// The root file now drops gate, adds one and excludes acme; the bus holds its snapshot.
|
||||
writeJson(join(dir, "agents.json"), { agentsVersion: 1, agents: { sec: { definition: "security-reviewer" }, gate: { definition: "merge-gate", excludeOrgs: ["acme"] } } });
|
||||
const later = loadWithCopies("acme", { dir, rolesDir, snapshot });
|
||||
assert.deepEqual(Object.keys(later.copies), ["gate"]);
|
||||
assert.equal(later.roles.gate.definition, "merge-gate");
|
||||
assert.equal(Object.hasOwn(later.roles, "sec"), false);
|
||||
// A broken root file is no different: nothing after boot reads it.
|
||||
writeFileSync(join(dir, "agents.json"), "{ not json");
|
||||
assert.equal(loadWithCopies("acme", { dir, rolesDir, snapshot }).roles.gate.definition, "merge-gate");
|
||||
// The next boot reads it again.
|
||||
writeJson(join(dir, "agents.json"), { agentsVersion: 1, agents: { sec: { definition: "security-reviewer" } } });
|
||||
const next = materializeCopies({ orgId: "acme", doc, root: loadRootAgents(dir, { rolesDir }), rolesDir });
|
||||
assert.deepEqual(Object.keys(next.copies), ["sec"]);
|
||||
// Nothing that runs after boot reads the root file: only the cli's boot step names loadRootAgents.
|
||||
for (const src of ["packages/bus/src/runtime.mjs", "packages/bus/src/broker.mjs", "packages/cli/src/launcher.mjs", "packages/organization/src/agent-copies.mjs"])
|
||||
assert.equal(/loadRootAgents\s*\(/.test(readFileSync(join(REPO, src), "utf8")), false, src);
|
||||
});
|
||||
|
||||
test("A3: project agents copy into their own project, and a refused union refuses every agent it defines", () => {
|
||||
const dir = tmp();
|
||||
mkdirSync(join(dir, "web"), { recursive: true });
|
||||
const doc = org(dir, "acme", (d) => {
|
||||
d.projects.stack.agents = { checker: { definition: "merge-gate" } };
|
||||
// web excludes an agent nothing defines, so its union refuses.
|
||||
d.projects.web = { root: join(dir, "web"), agents: { checker: { definition: "merge-gate" }, ghost: { exclude: true } } };
|
||||
return d;
|
||||
});
|
||||
const { snapshot, business } = boot(dir, "acme", doc, root({}));
|
||||
assert.equal(copyInstanceName("checker", "stack"), "checker.stack");
|
||||
assert.deepEqual({ ...snapshot.copies["checker.stack"], vars: undefined }, { name: "checker", level: "project", project: "stack", definition: "merge-gate", vars: undefined, state: "instance" });
|
||||
assert.equal(business.roles["checker.stack"].definition, "merge-gate");
|
||||
assert.equal(Object.hasOwn(business.roles, "checker.web"), false);
|
||||
const refusal = copyRefusal(business, "checker.web");
|
||||
assert.equal(refusal.code, "project-refused");
|
||||
assert.match(refusal.message, /^checker\.web can't launch in acme: project web refuses its agents: /);
|
||||
assert.match(refusal.message, /ghost/);
|
||||
// A project that stops being declared after boot takes its copy with it.
|
||||
const { stack, ...rest } = doc.projects;
|
||||
const later = withCopies({ ...doc, projects: rest }, businessFilePath("acme", dir), { rolesDir, snapshot });
|
||||
assert.equal(Object.hasOwn(later.roles, "checker.stack"), false);
|
||||
assert.equal(copyRefusal(later, "checker.stack").code, "project-refused");
|
||||
});
|
||||
|
||||
test("A3: an organization instance of the same name wins over a root or project copy, at boot and after it, and the copy is gone, not refused", () => {
|
||||
const dir = tmp();
|
||||
const doc = org(dir, "acme", (d) => {
|
||||
d.projects.stack.agents = { checker: { definition: "merge-gate" } };
|
||||
// A raw org instance already named like the project copy wins at boot.
|
||||
d.roles["checker.stack"] = { definition: "security-reviewer" };
|
||||
return d;
|
||||
});
|
||||
const { snapshot, business } = boot(dir, "acme", doc, root({ gate: { definition: "merge-gate" } }));
|
||||
assert.equal(business.copies.gate.state, "instance");
|
||||
assert.equal(Object.hasOwn(snapshot.copies, "checker.stack"), false);
|
||||
assert.equal(business.roles["checker.stack"].definition, "security-reviewer");
|
||||
assert.equal(copyRefusal(business, "checker.stack"), null);
|
||||
// An org instance added after boot replaces the root copy: the copy leaves the lookup, so nothing
|
||||
// refuses the organization's own instance.
|
||||
const later = withCopies({ ...doc, roles: { ...doc.roles, gate: { definition: "security-reviewer" } } }, businessFilePath("acme", dir), { rolesDir, snapshot });
|
||||
assert.equal(later.roles.gate.definition, "security-reviewer", "the organization file's own instance");
|
||||
assert.equal(Object.hasOwn(later.copies, "gate"), false);
|
||||
assert.equal(copyRefusal(later, "gate"), null);
|
||||
assert.equal(projectRefusal(later, "gate", "stack"), null);
|
||||
});
|
||||
|
||||
test("A3: two agents whose instance names come out the same both refuse, naming each; neither overwrites the other", () => {
|
||||
const dir = tmp();
|
||||
mkdirSync(join(dir, "a.b"), { recursive: true });
|
||||
mkdirSync(join(dir, "b"), { recursive: true });
|
||||
const doc = org(dir, "acme", (d) => {
|
||||
d.projects.stack.agents = { checker: { definition: "merge-gate" } };
|
||||
// Project a.b's c and project b's c.a both come out c.a.b.
|
||||
d.projects["a.b"] = { root: join(dir, "a.b"), agents: { c: { definition: "merge-gate" } } };
|
||||
d.projects.b = { root: join(dir, "b"), agents: { "c.a": { definition: "merge-gate" } } };
|
||||
return d;
|
||||
});
|
||||
// Root checker.stack is a different agent from project stack's checker.
|
||||
const { snapshot, business } = boot(dir, "acme", doc, root({ "checker.stack": { definition: "security-reviewer" } }));
|
||||
for (const [instance, claims] of [["checker.stack", /root agent checker\.stack and project stack's checker/], ["c.a.b", /project a\.b's c and project b's c\.a/]]) {
|
||||
assert.equal(snapshot.copies[instance].state, "refused", instance);
|
||||
assert.match(snapshot.copies[instance].reason, claims);
|
||||
assert.equal(Object.hasOwn(business.roles, instance), false, instance);
|
||||
assert.ok(copyRefusal(business, instance), instance);
|
||||
}
|
||||
// The view lists every claimant, so a reader finds each agent's copy by its name and project.
|
||||
assert.deepEqual(business.copies["checker.stack"].claims, [{ name: "checker.stack", project: null }, { name: "checker", project: "stack" }]);
|
||||
assert.deepEqual(business.copies["c.a.b"].claims, [{ name: "c", project: "a.b" }, { name: "c.a", project: "b" }]);
|
||||
});
|
||||
|
||||
test("A3: a collision the current file brings in after boot refuses every claimant it reaches, and lifts when the file drops it", () => {
|
||||
const dir = tmp();
|
||||
mkdirSync(join(dir, "a.b"), { recursive: true });
|
||||
mkdirSync(join(dir, "b"), { recursive: true });
|
||||
const file = businessFilePath("acme", dir);
|
||||
const doc = org(dir, "acme", (d) => {
|
||||
d.projects["a.b"] = { root: join(dir, "a.b"), agents: { c: { definition: "merge-gate" } } };
|
||||
d.projects.b = { root: join(dir, "b") };
|
||||
return d;
|
||||
});
|
||||
const { snapshot, business } = boot(dir, "acme", doc, root({ "new.stack": { definition: "security-reviewer" } }));
|
||||
assert.equal(business.copies["c.a.b"].state, "instance");
|
||||
assert.equal(business.copies["new.stack"].state, "instance");
|
||||
// Project b's c.a and project stack's new now come out as the names of copies made at boot.
|
||||
const edited = structuredClone(doc);
|
||||
edited.projects.b.agents = { "c.a": { definition: "merge-gate" } };
|
||||
edited.projects.stack.agents = { new: { definition: "merge-gate" } };
|
||||
const later = withCopies(edited, file, { rolesDir, snapshot });
|
||||
for (const [instance, claims, reason] of [
|
||||
["c.a.b", [{ name: "c", project: "a.b" }, { name: "c.a", project: "b" }], "the instance name c.a.b is claimed by project a.b's c and project b's c.a"],
|
||||
["new.stack", [{ name: "new.stack", project: null }, { name: "new", project: "stack" }], "the instance name new.stack is claimed by root agent new.stack and project stack's new"],
|
||||
]) {
|
||||
assert.equal(later.copies[instance].state, "refused", instance);
|
||||
assert.deepEqual(later.copies[instance].claims, claims, instance);
|
||||
assert.equal(later.copies[instance].reason, reason, instance);
|
||||
assert.equal(Object.hasOwn(later.roles, instance), false, instance);
|
||||
assert.ok(copyRefusal(later, instance), instance);
|
||||
}
|
||||
// Without the new defines, both copies are instances again.
|
||||
const back = withCopies(doc, file, { rolesDir, snapshot });
|
||||
assert.equal(back.copies["c.a.b"].state, "instance");
|
||||
assert.equal(back.copies["new.stack"].state, "instance");
|
||||
});
|
||||
|
||||
test("A3: a project define whose vars change after boot refuses its copy until the next boot", () => {
|
||||
const dir = tmp();
|
||||
const file = businessFilePath("acme", dir);
|
||||
const doc = org(dir, "acme", (d) => { d.projects.stack.agents = { checker: { definition: "merge-gate" } }; return d; });
|
||||
const { snapshot, business } = boot(dir, "acme", doc, root({}));
|
||||
assert.equal(business.copies["checker.stack"].state, "instance");
|
||||
const edited = structuredClone(doc);
|
||||
edited.projects.stack.agents.checker.vars = { "limits.tools": ["read"] };
|
||||
const later = withCopies(edited, file, { rolesDir, snapshot });
|
||||
const r = copyRefusal(later, "checker.stack");
|
||||
assert.equal(r.code, "project-refused");
|
||||
assert.match(r.message, /project stack changed checker's vars since the bus started; the change takes effect at the next start$/);
|
||||
assert.equal(Object.hasOwn(later.roles, "checker.stack"), false);
|
||||
// The next boot makes the copy from the new vars.
|
||||
const next = boot(dir, "acme", edited, root({})).business;
|
||||
assert.equal(next.copies["checker.stack"].state, "instance");
|
||||
assert.deepEqual(next.roles["checker.stack"].vars["limits.tools"], ["read"]);
|
||||
});
|
||||
|
||||
test("A3: each project's union is recomputed from the current file: a refused union refuses its own copy and every agent in it; exclude and narrow reach the instance", () => {
|
||||
const dir = tmp();
|
||||
mkdirSync(join(dir, "web"), { recursive: true });
|
||||
const file = businessFilePath("acme", dir);
|
||||
const doc = org(dir, "acme", (d) => {
|
||||
d.projects.stack.agents = { checker: { definition: "merge-gate" } };
|
||||
d.projects.web = { root: join(dir, "web") };
|
||||
d.roles["gate-org"] = { definition: "merge-gate" };
|
||||
return d;
|
||||
});
|
||||
const { snapshot, business } = boot(dir, "acme", doc, root({ gate: { definition: "merge-gate" } }));
|
||||
assert.deepEqual(business.unions.stack, { ok: true, agents: [...Object.keys(doc.roles), "checker.stack", "gate"].sort() });
|
||||
assert.equal(projectRefusal(business, "checker.stack", "web").code, "project-agent-elsewhere");
|
||||
assert.equal(projectRefusal(business, "gate", "web"), null);
|
||||
const system = systemFor(dir);
|
||||
const tools = (b, instance) => resolveInstance({ system, business: b, instance }).limits.tools;
|
||||
assert.ok(tools(business, "gate").includes("grep"));
|
||||
// After boot: stack excludes something nothing defines, so its union refuses; web excludes gate and
|
||||
// narrows gate-org's tools.
|
||||
const edited = structuredClone(doc);
|
||||
edited.projects.stack.agents.ghost = { exclude: true };
|
||||
edited.projects.web.agents = { gate: { exclude: true }, "gate-org": { vars: { "limits.tools": ["read"] } } };
|
||||
const later = withCopies(edited, file, { rolesDir, snapshot });
|
||||
assert.equal(later.unions.stack.ok, false);
|
||||
assert.match(later.unions.stack.reason, /ghost/);
|
||||
// stack's own copy refuses, naming the project and why.
|
||||
assert.equal(Object.hasOwn(later.roles, "checker.stack"), false);
|
||||
const own = copyRefusal(later, "checker.stack");
|
||||
assert.equal(own.code, "project-refused");
|
||||
assert.match(own.message, /^checker\.stack can't launch in acme: project stack refuses its agents: .*ghost/);
|
||||
// Every inherited agent refuses there too.
|
||||
for (const instance of ["gate", "coder", "gate-org"]) {
|
||||
const r = projectRefusal(later, instance, "stack");
|
||||
assert.equal(r.code, "project-refused", instance);
|
||||
assert.match(r.message, new RegExp(`^${instance} can't work in project stack: project stack refuses its agents: .*ghost`));
|
||||
}
|
||||
assert.match(projectRefusal(later, "gate", "web").message, /the project excludes it$/);
|
||||
assert.equal(projectRefusal(later, "coder", "web"), null);
|
||||
// The narrow reaches the instance itself, so its resolved tools and actions are narrowed.
|
||||
assert.deepEqual(later.roles["gate-org"].vars["limits.tools"], ["read"]);
|
||||
assert.deepEqual(tools(later, "gate-org"), ["read"]);
|
||||
// A project that stops defining its agent takes the copy with it.
|
||||
const dropped = structuredClone(doc);
|
||||
dropped.projects.stack.agents = { other: { definition: "merge-gate" } };
|
||||
assert.match(copyRefusal(withCopies(dropped, file, { rolesDir, snapshot }), "checker.stack").message, /project stack no longer defines checker as merge-gate$/);
|
||||
});
|
||||
|
||||
test("A3: a project narrowing a root copy narrows that copy; an organization with only project entries still gets its unions", () => {
|
||||
const dir = tmp();
|
||||
const doc = org(dir, "acme", (d) => {
|
||||
d.projects.stack.agents = { gate: { vars: { "limits.tools": ["read", "ls"] } } };
|
||||
return d;
|
||||
});
|
||||
const { business } = boot(dir, "acme", doc, root({ gate: { definition: "merge-gate" } }));
|
||||
assert.deepEqual(resolveInstance({ system: systemFor(dir), business, instance: "gate" }).limits.tools, ["read", "ls"]);
|
||||
// No root agents and no copy: the project's entries still shape the business.
|
||||
const only = org(dir, "beta", (d) => { d.projects.stack.agents = { coder: { vars: { "limits.tools": ["read"] } } }; return d; });
|
||||
const plain = loadWithCopies("beta", { dir, rolesDir, snapshot: null });
|
||||
assert.deepEqual(plain.unions.stack.agents, Object.keys(only.roles).sort());
|
||||
assert.deepEqual(plain.roles.coder.vars["limits.tools"], ["read"]);
|
||||
assert.equal(Object.hasOwn(plain, "copies"), false);
|
||||
});
|
||||
@@ -59,7 +59,7 @@ test("row 325: the old mosaic-stack project key refuses against those same proje
|
||||
const r = x.validate();
|
||||
assert.equal(r.status, 2, r.stdout + r.stderr);
|
||||
assert.equal(r.stdout, "");
|
||||
assert.match(r.stderr, /has id stack, but business mosaic-stack declares it as mosaic-stack/);
|
||||
assert.match(r.stderr, /has id that does not match the declared project key/);
|
||||
assert.ok(r.stderr.includes(x.projectFile));
|
||||
assert.doesNotMatch(r.stderr, /no project file/);
|
||||
});
|
||||
@@ -77,7 +77,7 @@ test("validate: project files", () => {
|
||||
assert.deepEqual(JSON.parse(r.out).projects, { stack: "valid" });
|
||||
|
||||
writeJson(file, { projectVersion: 1, id: "other" });
|
||||
assert.match(run(s, "validate", "acme").err, /has id other, but business acme declares it as stack/);
|
||||
assert.match(run(s, "validate", "acme").err, /has id that does not match the declared project key/);
|
||||
assert.equal(run(s, "validate", "acme").code, 2);
|
||||
writeJson(file, { projectVersion: 1, id: "stack", roles: { ghost: {} } });
|
||||
const ghost = run(s, "validate", "acme");
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
function scratch(t) { const root = mkdtempSync(join(tmpdir(), 'r4-')); t.after(() => rmSync(root, { recursive: true, force: true })); return root; }
|
||||
function safe(label, Type, props = {}, omitted = secret) { return error => {
|
||||
assert.ok(error instanceof Type, error.stack);
|
||||
for (const [key, value] of Object.entries(props)) assert.equal(error[key], value);
|
||||
assert.ok(error.message.includes(label), error.message);
|
||||
assert.doesNotMatch(error.message + error.stack, /sk-test|NOT-A-SECRET|Unexpected token/);
|
||||
assert.ok(!error.message.includes(omitted), error.message);
|
||||
assert.equal(error.cause, undefined);
|
||||
return true;
|
||||
}; }
|
||||
import { checkAccess } from '../src/access.mjs';
|
||||
import { checkAppearance } from '../src/appearance.mjs';
|
||||
import { loadProject } from '../src/project.mjs';
|
||||
import { validateRoleDocument } from '../src/role.mjs';
|
||||
import { covers } from '../src/vocabulary.mjs';
|
||||
import { BusinessError } from '../src/errors.mjs';
|
||||
const context = { providers: ['test'], peers: [], businesses: { acme: { human: 'jason', instances: ['coder'] } }, ssoSubjects: [] };
|
||||
const access = () => ({ accessVersion: 1, revision: 1, users: [{ id: 'bean', home: 'local', subjects: [{ provider: 'test', sub: 'user' }] }], grants: [{ id: 'grant', user: 'bean', business: 'acme', projects: ['*'], instances: ['coder'], level: 'viewer' }] });
|
||||
for (const [field, edit] of [
|
||||
['users[0].home', d => { d.users[0].home = secret; }],
|
||||
['users[0].subjects[0].provider', d => { d.users[0].subjects[0].provider = secret; }],
|
||||
...['user', 'business', 'level'].map(k => [`grants[0].${k}`, d => { d.grants[0][k] = secret; }]),
|
||||
['grants[0].projects[0]', d => { d.grants[0].projects = [secret]; }],
|
||||
['accessVersion', d => { d.accessVersion = secret; }],
|
||||
]) test(`access ${field} omits the refused value`, () => {
|
||||
const d = access(); edit(d); assert.throws(() => checkAccess(d, context), safe(field, BusinessError, { exitCode: 2 }));
|
||||
});
|
||||
for (const k of ['theme', 'mode']) test(`appearance ${k} omits its value`, () => {
|
||||
assert.throws(() => checkAppearance({ [k]: secret }, 'appearance'), safe(k, BusinessError, { exitCode: 2 }));
|
||||
});
|
||||
test('project root omits its configured value', () => assert.throws(() => loadProject(secret), safe('project root', BusinessError, { exitCode: 2 })));
|
||||
test('covers names the level field, not its value', () => assert.throws(() => covers({}, {}, new Date(), { level: secret }), safe('level', TypeError)));
|
||||
const role = () => ({ roleVersion: 2, name: 'r', title: 'R', contract: 'r.md', tools: ['read'], network: 'none', authority: { withinRole: [], crossRole: [] }, credentials: [] });
|
||||
for (const [field, edit] of [
|
||||
['skill', d => { d.skills = [secret + '/']; }],
|
||||
['contract', d => { d.contract = secret; }],
|
||||
['tool', d => { d.tools = [secret]; }],
|
||||
['authority.withinRole', d => { d.authority.withinRole = [secret]; }],
|
||||
['hireable', d => { d.hireable = [secret.toLowerCase()]; }],
|
||||
['gitea scope', d => { d.credentials = [{ service: 'gitea', scopes: [secret] }]; }],
|
||||
['vikunja route group', d => { d.credentials = [{ service: 'vikunja', scopes: { [secret]: ['read'] } }]; }],
|
||||
['vikunja verb', d => { d.credentials = [{ service: 'vikunja', scopes: { tasks: [secret] } }]; }],
|
||||
['name', d => { d.name = secret.toLowerCase(); }],
|
||||
]) test(`role ${field} omits the configured value`, t => {
|
||||
const root = scratch(t); writeFileSync(join(root, 'r.md'), '# R\n\n## Authority\n');
|
||||
const d = role(); edit(d);
|
||||
assert.throws(() => validateRoleDocument(d, join(root, 'r.json')), safe(field, BusinessError, { exitCode: 2 }, secret.toLowerCase()));
|
||||
});
|
||||
// This branch is reachable only with already-validated service names; a sentinel service
|
||||
// is refused earlier. Pin the same no-value rule with an admissible service.
|
||||
test('creative service refusal omits the validated service list', t => {
|
||||
const root = scratch(t); writeFileSync(join(root, 'companion.md'), '# R\n\n## Authority\n');
|
||||
const d = { ...role(), name: 'companion', contract: 'companion.md', messaging: { sendTo: ['human'], receiveFrom: ['human'] }, credentials: [{ service: 'vikunja', scopes: { tasks: ['read_one'] } }] };
|
||||
assert.throws(() => validateRoleDocument(d, join(root, 'companion.json')), safe('credential', BusinessError, { exitCode: 2 }, 'vikunja'));
|
||||
});
|
||||
test('role duplicate skill omits the accepted list value', t => {
|
||||
const root = scratch(t); writeFileSync(join(root, 'r.md'), '# R\n\n## Authority\n');
|
||||
const d = { ...role(), skills: [secret, secret] };
|
||||
assert.throws(() => validateRoleDocument(d, join(root, 'r.json')), safe('role skills', BusinessError, { exitCode: 2 }));
|
||||
});
|
||||
for (const at of ['user', 'grant', 'scope', 'human']) test(`access ${at} duplicate/reserved refusal omits accepted ids`, () => {
|
||||
const value = secret.toLowerCase(), d = access(), ctx = structuredClone(context);
|
||||
if (at === 'user') { d.users[0].id = value; d.users.push({ ...structuredClone(d.users[0]), subjects: [{ provider: 'test', sub: 'second' }] }); }
|
||||
if (at === 'grant') { d.grants[0].id = value; d.grants.push(structuredClone(d.grants[0])); }
|
||||
if (at === 'scope') { ctx.businesses.acme.instances = [value]; d.grants[0].instances = [value, value]; }
|
||||
if (at === 'human') { ctx.businesses.acme.human = value; d.users[0].id = value; }
|
||||
assert.throws(() => checkAccess(d, ctx), safe(at === 'scope' ? 'instances' : '.id', BusinessError, { exitCode: 2 }, value));
|
||||
});
|
||||
@@ -164,7 +164,7 @@ test("the record carries what the broker and launcher need", () => {
|
||||
const s = setup();
|
||||
const pm = resolve(s, "pm");
|
||||
assert.deepEqual(Object.keys(pm).sort(), [
|
||||
"business", "contract", "credentials", "definition", "digest", "holder", "instance", "launch", "limits", "project", "provenance", "skills", "tracker", "vars", "webSource",
|
||||
"business", "contract", "credentials", "definition", "digest", "holder", "instance", "launch", "limits", "project", "provenance", "rails", "skills", "tracker", "vars", "webSource",
|
||||
]);
|
||||
assert.equal(pm.contract, join(REPO_ROLES, "pm.md"));
|
||||
assert.equal(pm.holder, "sage");
|
||||
|
||||
@@ -174,7 +174,7 @@ test("row 64 skills: optional, distinct skill directory names, none on version 1
|
||||
assert.deepEqual(role.skills, ["ms-unslop", "ms_sdlc.v2"]);
|
||||
assert.ok(Object.isFrozen(role.skills));
|
||||
refuses(() => check({ skills: "ms-unslop" }), /role skills must be an array/);
|
||||
refuses(() => check({ skills: ["ms-unslop", "ms-unslop"] }), /duplicate entry in role skills: "ms-unslop"/);
|
||||
refuses(() => check({ skills: ["ms-unslop", "ms-unslop"] }), /duplicate entry in role skills/);
|
||||
for (const bad of [".", "..", "", "a/b", "../ms-unslop", "ms unslop", 7, null]) {
|
||||
refuses(() => check({ skills: [bad] }), /role skill must be a skill directory name/);
|
||||
}
|
||||
@@ -189,7 +189,7 @@ test("credentials: Gitea scopes", () => {
|
||||
refuses(() => gitea(["write:admin"]), /unsupported gitea scope/);
|
||||
refuses(() => gitea(["all"]), /unsupported gitea scope/);
|
||||
refuses(() => gitea(["sudo:repository"]), /unsupported gitea scope/);
|
||||
refuses(() => gitea(["read:issue", "write:issue"]), /name issue twice/);
|
||||
refuses(() => gitea(["read:issue", "write:issue"]), /name a category twice/);
|
||||
refuses(() => gitea([]), /must not be empty/);
|
||||
});
|
||||
|
||||
@@ -255,7 +255,7 @@ test("M1: a creative role file wider than read and answer is refused", () => {
|
||||
};
|
||||
assert.doesNotThrow(creative("ideas", { tools: ["read", "grep", "find", "ls", "web"] }));
|
||||
for (const tool of ["write", "edit", "bash"]) {
|
||||
refuses(creative("ideas", { tools: ["read", tool] }), new RegExp(`^creative role ideas may not name tools ${tool} `));
|
||||
refuses(creative("ideas", { tools: ["read", tool] }), /^creative role ideas may not name tools outside the allowed set /);
|
||||
}
|
||||
const gitea = [{ service: "gitea", scopes: ["read:user", "write:repository"] }];
|
||||
assert.doesNotThrow(creative("ideas", { authority: { withinRole: ["message.send", "library.write"], crossRole: [] }, credentials: gitea }));
|
||||
@@ -268,9 +268,9 @@ test("M1: a creative role file wider than read and answer is refused", () => {
|
||||
refuses(creative("proofreader", { authority: { withinRole: ["message.send"], crossRole: ["library.write"] } }), only);
|
||||
// Row 285: library.write with any credential but gitea refuses.
|
||||
refuses(creative("ideas", { authority: { withinRole: ["message.send", "library.write"], crossRole: [] }, credentials: [...gitea, { service: "vikunja", scopes: { tasks: ["read_one"] } }] }),
|
||||
/^creative role ideas may hold only the gitea credential \(got vikunja\)$/);
|
||||
/^creative role ideas may hold only the gitea credential$/);
|
||||
refuses(creative("ideas", { authority: { withinRole: ["message.send", "library.write"], crossRole: [] }, credentials: [{ service: "host" }] }),
|
||||
/^creative role ideas may hold only the gitea credential \(got host\)$/);
|
||||
/^creative role ideas may hold only the gitea credential$/);
|
||||
refuses(creative("continuity", { messaging: undefined }), /^creative role continuity requires "messaging"$/);
|
||||
refuses(creative("research", { messaging: { sendTo: ["human", "pm"], receiveFrom: ["human"] } }), /^creative role research messaging.sendTo may name only human$/);
|
||||
refuses(creative("research", { messaging: { sendTo: ["human"], receiveFrom: ["assistant"] } }), /^creative role research messaging.receiveFrom may name only human$/);
|
||||
@@ -307,14 +307,14 @@ test("hireable: a list naming a definition that holds any refused action refuses
|
||||
for (const action of REFUSED_FOR_HIRE) {
|
||||
for (const key of ["withinRole", "crossRole"]) {
|
||||
const dir = hireDir({ chief: { hireable: ["worker"] }, worker: { [key]: [action], ...(key === "crossRole" ? { withinRole: [] } : {}) } });
|
||||
refuses(() => loadRole(dir, "chief"), new RegExp(`hireable names worker, which holds ${action.replace(/\./g, "\\.")}$`));
|
||||
refuses(() => loadRole(dir, "chief"), /hireable names a definition which holds an action outside the hireable ceiling$/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("hireable: a mixed definition refuses, a clean list loads, and the named file's own hireable isn't read", () => {
|
||||
const mixed = hireDir({ chief: { hireable: ["worker"] }, worker: { withinRole: ["message.send", "role.launch"] } });
|
||||
refuses(() => loadRole(mixed, "chief"), /hireable names worker, which holds role\.launch/);
|
||||
refuses(() => loadRole(mixed, "chief"), /hireable names a definition which holds an action outside the hireable ceiling/);
|
||||
const clean = hireDir({
|
||||
chief: { hireable: ["worker", "helper"] },
|
||||
worker: { withinRole: [...HIRE_ALLOWED] },
|
||||
@@ -323,15 +323,15 @@ test("hireable: a mixed definition refuses, a clean list loads, and the named fi
|
||||
});
|
||||
assert.deepEqual(loadRole(clean, "chief").hireable, ["worker", "helper"]);
|
||||
assert.ok(Object.isFrozen(loadRole(clean, "chief").hireable));
|
||||
refuses(() => loadRole(clean, "helper"), /hireable names nobody, which has no role file/);
|
||||
refuses(() => loadRole(clean, "helper"), /hireable names a definition which has no role file/);
|
||||
});
|
||||
|
||||
test("hireable: a missing, version 1, duplicate or badly named entry refuses, and the key is optional", () => {
|
||||
refuses(() => loadRole(hireDir({ chief: { hireable: ["ghost"] } }), "chief"), /hireable names ghost, which has no role file/);
|
||||
refuses(() => loadRole(hireDir({ chief: { hireable: ["old"] }, old: 1 }), "chief"), /hireable names old, which is role version 1/);
|
||||
refuses(() => loadRole(hireDir({ chief: { hireable: ["ghost"] } }), "chief"), /hireable names a definition which has no role file/);
|
||||
refuses(() => loadRole(hireDir({ chief: { hireable: ["old"] }, old: 1 }), "chief"), /hireable names a definition which is role version 1/);
|
||||
// Row 213 (M10): a creative definition refuses by name, before its file is read.
|
||||
for (const name of CREATIVE_DEFINITIONS) {
|
||||
refuses(() => loadRole(hireDir({ chief: { hireable: [name] } }), "chief"), new RegExp(`hireable names ${name}, a creative definition only the human adds$`));
|
||||
refuses(() => loadRole(hireDir({ chief: { hireable: [name] } }), "chief"), /hireable names a creative definition only the human adds$/);
|
||||
}
|
||||
refuses(() => loadRole(hireDir({ chief: { hireable: ["w", "w"] }, w: {} }), "chief"), /duplicate entry in role hireable/);
|
||||
refuses(() => loadRole(hireDir({ chief: { hireable: ["../w"] } }), "chief"), /role hireable entry/);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { REPO_ROLES } from "./helpers.mjs";
|
||||
@@ -41,3 +42,22 @@ test("the assistant summarizes a spec review in its own words and doesn't repeat
|
||||
// The user's own rulings still go to the chief of staff verbatim; only a review result is summarized.
|
||||
assert.match(text, /Relay each ruling the user gives verbatim to the chief\s+of\s+staff/);
|
||||
});
|
||||
|
||||
// TI: exact authorized contract wording, independently pinned for review.
|
||||
test("TI contracts point at the generated and live caller rails", () => {
|
||||
const pins = {
|
||||
"coder": "9a31e19cbea5953fd9598def446e0e5e351869498f593f46468cf03fc0e2759b",
|
||||
"cto": "c4c53b00eea1a6c73c90f2b5a9e54960848972a517c4e6ee47198e91dc8ddf45",
|
||||
"merge-gate": "8d39144cab994781cadf7ac0dbfc41a1dd8a3c59ef66d916c79e9d63324572ad",
|
||||
"pm": "0f7cbae8085709ab2907483543681ff7dc002ac2f9fea14d0b840213e9cf6d56",
|
||||
"reviewer": "5269da2de779f3e399838a1c8357201862a4bd25c03de71ca8ebb31978dea6b7",
|
||||
"security-reviewer": "eb664413c47d405ffb593cf15d3278217f09c74ddef0a179dcf22d9bf041378a",
|
||||
};
|
||||
for (const [name, hash] of Object.entries(pins)) {
|
||||
const text = contract(name);
|
||||
assert.equal(createHash('sha256').update(text).digest('hex'), hash, name);
|
||||
assert.match(text, /"Your\s+rails"/);
|
||||
assert.match(text, /`my_rails`/);
|
||||
assert.doesNotMatch(text, /launch record you\s+received/);
|
||||
}
|
||||
});
|
||||
@@ -67,7 +67,7 @@ export function validateInterviewTemplate(value) {
|
||||
value.questions.forEach((q, i) => {
|
||||
if (!object(q) || !sameKeys(q, ['key', 'text'])) { problems.push(`questions[${i}] keys must be exactly key, text`); return; }
|
||||
if (typeof q.key !== 'string' || !KEY.test(q.key)) problems.push(`questions[${i}].key must be lower-case letters, digits and dashes`);
|
||||
else if (seen.has(q.key)) problems.push(`questions[${i}].key repeats ${q.key}`);
|
||||
else if (seen.has(q.key)) problems.push(`questions[${i}].key repeats an earlier question key`);
|
||||
seen.add(q.key);
|
||||
if (!line(q.text, 500)) problems.push(`questions[${i}].text must be one line of 1-500 characters`);
|
||||
});
|
||||
@@ -76,7 +76,7 @@ export function validateInterviewTemplate(value) {
|
||||
problems.push('prompt must be 1-200 lines of text, each at most 2000 characters without line breaks');
|
||||
else {
|
||||
const text = value.prompt.join('\n'), used = new Set([...text.matchAll(/\{([^{}]*)\}/g)].map(m => m[1]));
|
||||
for (const name of used) if (!PLACEHOLDERS.includes(name)) problems.push(`prompt uses unknown placeholder {${name}}`);
|
||||
for (const name of used) if (!PLACEHOLDERS.includes(name)) problems.push(`prompt uses unknown placeholder; expected one of ${PLACEHOLDERS.join(", ")}`);
|
||||
for (const name of REQUIRED) if (!used.has(name)) problems.push(`prompt must use {${name}}`);
|
||||
if (!text.trim()) problems.push('prompt must not be empty');
|
||||
}
|
||||
@@ -87,7 +87,7 @@ export function validateInterviewTemplate(value) {
|
||||
export function loadInterviewTemplate(url = INTERVIEW_TEMPLATE_URL) {
|
||||
let value;
|
||||
try { value = JSON.parse(readFileSync(url, 'utf8')); }
|
||||
catch (error) { throw new OnboardingError('onboarding-template-invalid', `interview template unreadable: ${error.message}`); }
|
||||
catch (error) { throw new OnboardingError('onboarding-template-invalid', `interview template unreadable: expected readable JSON in ${url}${error.code ? ` (${error.code})` : ""}`); }
|
||||
const problems = validateInterviewTemplate(value);
|
||||
if (problems.length) throw new OnboardingError('onboarding-template-invalid', problems.join('; '));
|
||||
return value;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
function scratch(t) { const root = mkdtempSync(join(tmpdir(), 'r4-')); t.after(() => rmSync(root, { recursive: true, force: true })); return root; }
|
||||
function safe(label, Type, props = {}, omitted = secret) { return error => {
|
||||
assert.ok(error instanceof Type, error.stack);
|
||||
for (const [key, value] of Object.entries(props)) assert.equal(error[key], value);
|
||||
assert.ok(error.message.includes(label), error.message);
|
||||
assert.doesNotMatch(error.message + error.stack, /sk-test|NOT-A-SECRET|Unexpected token/);
|
||||
assert.ok(!error.message.includes(omitted), error.message);
|
||||
assert.equal(error.cause, undefined);
|
||||
return true;
|
||||
}; }
|
||||
import { loadInterviewTemplate, OnboardingError } from '../src/onboarding.mjs';
|
||||
import { initProject, writeRecord, ProjectError } from '../src/index.mjs';
|
||||
test('onboarding template parser omits file bytes and keeps its code', t => {
|
||||
const file = join(scratch(t), 'template.json'); writeFileSync(file, secret);
|
||||
assert.throws(() => loadInterviewTemplate(file), safe(file, OnboardingError, { code: 'onboarding-template-invalid', stage: 'check' }));
|
||||
});
|
||||
test('project nonobject record already reports only its type (BASE control)', async t => {
|
||||
const root = scratch(t), context = { business: { id: 'acme', human: 'jason', projects: { stack: { root: join(root, 'code') } } }, project: 'stack', dataRoot: join(root, 'data') };
|
||||
await initProject(context);
|
||||
await assert.rejects(() => writeRecord({ ...context, path: 'requirements/REQ-1.json', record: JSON.stringify(secret), author: { name: 'Tester', email: '[email protected]' } }), safe('object', ProjectError, { code: 'project-record-invalid' }));
|
||||
});
|
||||
import { INTERVIEW_TEMPLATE_URL } from '../src/onboarding.mjs';
|
||||
for (const at of ['question', 'placeholder']) test(`template ${at} semantic refusal omits its value`, t => {
|
||||
const d = JSON.parse(readFileSync(INTERVIEW_TEMPLATE_URL, 'utf8'));
|
||||
if (at === 'question') { d.questions[0].key = secret.toLowerCase(); d.questions[1].key = secret.toLowerCase(); }
|
||||
else d.prompt.push(`{${secret}}`);
|
||||
const file = join(scratch(t), 'template.json'); writeFileSync(file, JSON.stringify(d));
|
||||
assert.throws(() => loadInterviewTemplate(file), safe(at === 'question' ? 'questions[1].key' : 'placeholder', OnboardingError, { code: 'onboarding-template-invalid' }, secret.toLowerCase()));
|
||||
});
|
||||
@@ -46,12 +46,12 @@ test('a replacement template is checked key by key and every problem is named',
|
||||
[t => { t.drafts.requirementPrefix = '-x'; }, 'drafts.requirementPrefix must start a record id'],
|
||||
[t => { delete t.drafts.prd; }, 'drafts keys must be exactly'],
|
||||
[t => { t.questions = []; }, 'questions must list 1-12 questions'],
|
||||
[t => { t.questions[1].key = t.questions[0].key; }, 'questions[1].key repeats outcome'],
|
||||
[t => { t.questions[1].key = t.questions[0].key; }, 'questions[1].key repeats an earlier question key'],
|
||||
[t => { t.questions[0].key = 'Bad Key'; }, 'questions[0].key must be lower-case'],
|
||||
[t => { t.questions[0].text = ''; }, 'questions[0].text must be one line'],
|
||||
[t => { t.questions[0].extra = 1; }, 'questions[0] keys must be exactly key, text'],
|
||||
[t => { t.prompt = ['line\nbreak']; }, 'prompt must be 1-200 lines'],
|
||||
[t => { t.prompt.push('{secret}'); }, 'prompt uses unknown placeholder {secret}'],
|
||||
[t => { t.prompt.push('{secret}'); }, 'prompt uses unknown placeholder; expected one of'],
|
||||
[t => { t.prompt = t.prompt.map(l => l.replaceAll('{questions}', 'the questions')); }, 'prompt must use {questions}'],
|
||||
[t => { t.prompt = t.prompt.map(l => l.replaceAll('{northStarDraft}', 'a doc')); }, 'prompt must use {northStarDraft}'],
|
||||
];
|
||||
|
||||
@@ -99,18 +99,18 @@ export function checkCellText(v, what, opts) {
|
||||
|
||||
// An op id a caller chose: 8 to 72 characters, not ending in `.outcome`.
|
||||
export function checkCallerOpId(v, what = "op id") {
|
||||
if (typeof v !== "string" || !CALLER_OP_RE.test(v)) throw refuse(`${what} ${JSON.stringify(v)} must match ${CALLER_OP_RE.source} (8 to 72 characters)`);
|
||||
if (v.endsWith(".outcome")) throw refuse(`${what} ${JSON.stringify(v)} ends in .outcome, which is reserved`);
|
||||
if (typeof v !== "string" || !CALLER_OP_RE.test(v)) throw refuse(`${what} must match ${CALLER_OP_RE.source} (8 to 72 characters)`);
|
||||
if (v.endsWith(".outcome")) throw refuse(`${what} ends in .outcome, which is reserved`);
|
||||
return v;
|
||||
}
|
||||
|
||||
export function checkName(v, what) {
|
||||
if (typeof v !== "string" || !NAME_RE.test(v)) throw refuse(`${what} must be a seat name (lowercase letters, digits, hyphens): ${JSON.stringify(v)}`);
|
||||
if (typeof v !== "string" || !NAME_RE.test(v)) throw refuse(`${what} must be a seat name (lowercase letters, digits, hyphens)`);
|
||||
return v;
|
||||
}
|
||||
|
||||
function checkId(v, what = "row id") {
|
||||
if (!Number.isInteger(v) || v < 1 || v > 1_000_000) throw refuse(`${what} must be a positive integer: ${JSON.stringify(v)}`);
|
||||
if (!Number.isInteger(v) || v < 1 || v > 1_000_000) throw refuse(`${what} must be a positive integer`);
|
||||
return v;
|
||||
}
|
||||
|
||||
@@ -148,14 +148,14 @@ function checkTime(v, what, { unknown = false, date = false } = {}) {
|
||||
// as March 2).
|
||||
const t = Date.parse(iso ? v : `${v}T00:00:00.000Z`);
|
||||
if (Number.isFinite(t) && new Date(t).toISOString().slice(0, v.length) === v) return v;
|
||||
throw refuse(`${what} is not a calendar ${iso ? "time" : "date"}: ${JSON.stringify(v)}`);
|
||||
throw refuse(`${what} is not a calendar ${iso ? "time" : "date"}`);
|
||||
}
|
||||
throw refuse(`${what} must be an ISO time${date ? " or date" : ""}${unknown ? " or \"unknown\"" : ""}: ${JSON.stringify(v)}`);
|
||||
throw refuse(`${what} must be an ISO time${date ? " or date" : ""}${unknown ? " or \"unknown\"" : ""}`);
|
||||
}
|
||||
|
||||
export function checkRepoPath(v, what) {
|
||||
if (typeof v !== "string" || !PATH_RE.test(v) || v.split("/").some((s) => s === "." || s === "..")) {
|
||||
throw refuse(`${what} must be a repo-relative path: ${JSON.stringify(v)}`);
|
||||
throw refuse(`${what} must be a repo-relative path`);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
@@ -163,7 +163,7 @@ export function checkRepoPath(v, what) {
|
||||
export function parseBriefSpec(spec) {
|
||||
if (typeof spec !== "string") throw refuse("brief must be PATH#ANCHOR");
|
||||
const i = spec.indexOf("#");
|
||||
if (i < 0) throw refuse(`brief must be PATH#ANCHOR, naming the heading: ${JSON.stringify(spec)}`);
|
||||
if (i < 0) throw refuse(`brief must be PATH#ANCHOR, naming the heading`);
|
||||
const path = checkRepoPath(spec.slice(0, i), "brief path");
|
||||
const anchor = checkCellText(spec.slice(i + 1), "brief anchor", { max: 200 });
|
||||
return { path, anchor };
|
||||
@@ -197,9 +197,9 @@ export function parseManifest(text) {
|
||||
const lines = text.slice(0, -1).split("\n");
|
||||
for (const line of lines) {
|
||||
const m = /^([0-9a-f]{64}) {2}(.+)$/.exec(line);
|
||||
if (!m) throw refuse(`candidate manifest line is not "<sha256> <path>": ${JSON.stringify(line)}`);
|
||||
if (!m) throw refuse(`candidate manifest line is not "<sha256> <path>"`);
|
||||
checkRepoPath(m[2], "candidate manifest path");
|
||||
if (seen.has(m[2])) throw refuse(`candidate manifest names ${m[2]} twice`);
|
||||
if (seen.has(m[2])) throw refuse(`candidate manifest names a path twice`);
|
||||
seen.add(m[2]);
|
||||
}
|
||||
return lines.length;
|
||||
@@ -222,7 +222,7 @@ export function attemptOf(row, op) {
|
||||
}
|
||||
|
||||
function checkCommentId(v, what = "comment id") {
|
||||
if (!Number.isSafeInteger(v) || v < 1) throw refuse(`${what} must be a positive integer: ${JSON.stringify(v)}`);
|
||||
if (!Number.isSafeInteger(v) || v < 1) throw refuse(`${what} must be a positive integer`);
|
||||
return v;
|
||||
}
|
||||
|
||||
@@ -250,7 +250,7 @@ function checkAttempt(a) {
|
||||
checkCallerOpId(a.op, "review attempt op");
|
||||
checkName(a.by, "review attempt by");
|
||||
checkTime(a.at, "review attempt at");
|
||||
if (!ATTEMPT_STATES.includes(a.state)) throw refuse(`review attempt state ${JSON.stringify(a.state)} is not one of ${ATTEMPT_STATES.join(", ")}`);
|
||||
if (!ATTEMPT_STATES.includes(a.state)) throw refuse(`review attempt state is not one of ${ATTEMPT_STATES.join(", ")}`);
|
||||
if (a.comment !== null) checkCommentId(a.comment);
|
||||
if ((a.state === "posted") !== (a.comment !== null)) throw refuse("a review attempt carries a comment id exactly when it is posted");
|
||||
if (a.transport !== null) checkTransport(a.transport);
|
||||
@@ -311,7 +311,7 @@ function checkRound(v, n) {
|
||||
}
|
||||
|
||||
export function validateRow(row) {
|
||||
keysExactly(row, ROW_KEYS, `row ${row?.id}`);
|
||||
keysExactly(row, ROW_KEYS, `row`);
|
||||
const w = `row ${row.id}`;
|
||||
checkId(row.id);
|
||||
checkCellText(row.piece, `${w} piece`, { max: 300 });
|
||||
@@ -319,7 +319,7 @@ export function validateRow(row) {
|
||||
checkIssues(row.issues, `${w} issues`);
|
||||
checkIssues(row.closes, `${w} closes`);
|
||||
for (const n of row.closes) if (!row.issues.includes(n)) throw refuse(`${w} closes #${n}, which is not in its issues`);
|
||||
if (!STATES.includes(row.state)) throw refuse(`${w} state ${JSON.stringify(row.state)} is not a state`);
|
||||
if (!STATES.includes(row.state)) throw refuse(`${w} state is not a state (expected one of ${STATES.join(", ")})`);
|
||||
if (row.state === "blocked") {
|
||||
if (!NON_TERMINAL.has(row.previousState)) throw refuse(`${w} is blocked and needs the non-terminal state it returns to`);
|
||||
checkCellText(row.blockedReason, `${w} blockedReason`);
|
||||
@@ -346,7 +346,7 @@ export function validateRow(row) {
|
||||
}
|
||||
if (row.claim !== null) {
|
||||
keysExactly(row.claim, ["seat", "op"], `${w} claim`);
|
||||
if (row.claim.seat !== row.owner) throw refuse(`${w} claim seat ${row.claim.seat} is not the owner ${row.owner}`);
|
||||
if (row.claim.seat !== row.owner) throw refuse(`${w} claim seat is not the owner`);
|
||||
checkCallerOpId(row.claim.op, `${w} claim op`);
|
||||
const s = row.state === "blocked" ? row.previousState : row.state;
|
||||
if (!CLAIM_KEPT.has(s)) throw refuse(`${w} is claimed but ${row.state}`);
|
||||
@@ -398,7 +398,7 @@ function nullable(v, fn) {
|
||||
|
||||
function sortedUnique(list, cmp) {
|
||||
const out = [...list].sort(cmp);
|
||||
for (let i = 1; i < out.length; i++) if (cmp(out[i], out[i - 1]) === 0) throw refuse(`repeated value ${JSON.stringify(out[i])}`);
|
||||
for (let i = 1; i < out.length; i++) if (cmp(out[i], out[i - 1]) === 0) throw refuse(`repeated value in list`);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -463,7 +463,7 @@ export function canonArgs(verb, a) {
|
||||
};
|
||||
}
|
||||
case "move":
|
||||
if (!STATES.includes(a.to)) throw refuse(`unknown state ${JSON.stringify(a.to)}; states: ${STATES.join(", ")}`);
|
||||
if (!STATES.includes(a.to)) throw refuse(`unknown state; states: ${STATES.join(", ")}`);
|
||||
return {
|
||||
id: checkId(a.id),
|
||||
to: a.to,
|
||||
@@ -503,7 +503,7 @@ export function canonArgs(verb, a) {
|
||||
if (a.verdict !== "approve" && a.verdict !== "changes") throw refuse("--verdict must be approve or changes");
|
||||
return { id: checkId(a.id), verdict: a.verdict, comment: checkCommentId(a.comment), candidate: checkDigest(a.candidate, "candidate") };
|
||||
default:
|
||||
throw refuse(`unknown verb ${JSON.stringify(verb)}`);
|
||||
throw refuse(`unknown verb; expected a queue operation`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,8 +517,8 @@ const isPriv = (by) => PRIVILEGED.has(by);
|
||||
|
||||
function ownerOrPriv(row, by, doing) {
|
||||
if (by === row.owner || isPriv(by)) return;
|
||||
const claimed = row.claim !== null ? `row ${row.id} is claimed by ${row.claim.seat}; ` : "";
|
||||
throw refuse(`${claimed}only the owner (${row.owner}) or a privileged actor may ${doing} row ${row.id}`);
|
||||
const claimed = row.claim !== null ? `row ${row.id} is claimed; ` : "";
|
||||
throw refuse(`${claimed}only the owner or a privileged actor may ${doing} row ${row.id}`);
|
||||
}
|
||||
|
||||
function requirePriv(by, doing) {
|
||||
@@ -580,7 +580,7 @@ function unresolvedAttempts(row) {
|
||||
|
||||
function refuseUnresolved(row, doing) {
|
||||
const open = unresolvedAttempts(row);
|
||||
if (open.length) throw refuse(`row ${row.id} has an unresolved review request: ${open.join(", ")}; resolve or abandon it before ${doing}`);
|
||||
if (open.length) throw refuse(`row ${row.id} has an unresolved review request; resolve or abandon it before ${doing}`);
|
||||
}
|
||||
|
||||
// A comment round leaves in-review, for done or for Jason's gate, only on
|
||||
@@ -589,7 +589,7 @@ function requireApprovals(row, cur, to) {
|
||||
if (row.reviewers.length === 0) throw refuse(`row ${row.id} lists no reviewers now; a privileged actor sets them before it ${to === "done" ? "closes" : `moves to ${to}`}`);
|
||||
const approved = new Set(cur.receipts.filter((r) => r.verdict === "approve").map((r) => r.reviewer));
|
||||
const missing = row.reviewers.filter((s) => !approved.has(s));
|
||||
if (missing.length) throw refuse(`row ${row.id} round ${cur.n} has no approval recorded by ${missing.join(", ")}`);
|
||||
if (missing.length) throw refuse(`row ${row.id} round ${cur.n} has no approval recorded by every listed reviewer`);
|
||||
}
|
||||
|
||||
// The request round an attempt belongs to, and the attempt.
|
||||
@@ -599,7 +599,7 @@ function findAttempt(row, op) {
|
||||
const i = r.attempts.findIndex((x) => x.op === op);
|
||||
if (i >= 0) return { round: r, index: i, attempt: r.attempts[i] };
|
||||
}
|
||||
throw refuse(`row ${row.id} has no review request ${op}`);
|
||||
throw refuse(`row ${row.id} has no review request matching the attempt op`);
|
||||
}
|
||||
|
||||
// A copy of `row` with one attempt of one round replaced.
|
||||
@@ -632,20 +632,20 @@ function outcomeState(attempt, t) {
|
||||
return t.outcome === "posted" && t.comment === attempt.comment ? "posted" : "conflict";
|
||||
}
|
||||
if (attempt.state === "abandoned") return t.outcome === "posted" ? "conflict" : "abandoned";
|
||||
throw refuse(`attempt ${attempt.op} is ${attempt.state}; it already has its outcome`);
|
||||
throw refuse(`attempt is ${attempt.state}; it already has its outcome`);
|
||||
}
|
||||
|
||||
function applyReview(rows, row, entry) {
|
||||
const a = entry.args;
|
||||
const by = entry.by;
|
||||
if (!row.review && entry.verb !== "review-request" && entry.verb !== "review-record") throw refuse(`row ${row.id} has no review request ${a.attempt}`);
|
||||
if (!row.review && entry.verb !== "review-request" && entry.verb !== "review-record") throw refuse(`row ${row.id} has no review request matching the attempt op`);
|
||||
switch (entry.verb) {
|
||||
case "review-request": {
|
||||
ownerOrPriv(row, by, "request review of");
|
||||
const cur = currentCommentRound(row, "review request");
|
||||
const live = cur.attempts.filter((x) => x.state !== "failed" && x.state !== "abandoned");
|
||||
refuseUnresolved(row, "a new request");
|
||||
if (live.length) throw refuse(`row ${row.id} round ${cur.n} already has a posted request (${live[0].op}, comment ${live[0].comment})`);
|
||||
if (live.length) throw refuse(`row ${row.id} round ${cur.n} already has a posted request`);
|
||||
const rounds = row.review.rounds.map((r) => (r.n === cur.n ? { ...r, attempts: [...r.attempts, newAttempt(entry)] } : r));
|
||||
return {
|
||||
row: { ...row, review: { rounds } },
|
||||
@@ -654,11 +654,11 @@ function applyReview(rows, row, entry) {
|
||||
};
|
||||
}
|
||||
case "review-outcome": {
|
||||
if (entry.op !== `${a.attempt}.outcome`) throw refuse(`an outcome's op is its attempt's op plus .outcome (${a.attempt}.outcome)`);
|
||||
if (entry.op !== `${a.attempt}.outcome`) throw refuse(`an outcome's op is its attempt's op plus .outcome`);
|
||||
const found = findAttempt(row, a.attempt);
|
||||
const att = found.attempt;
|
||||
if (by !== att.by) throw refuse(`only ${att.by}, who made request ${att.op}, records its outcome`);
|
||||
if (att.transport !== null) throw refuse(`request ${att.op} already has its outcome`);
|
||||
if (by !== att.by) throw refuse(`only the actor who made the request records its outcome`);
|
||||
if (att.transport !== null) throw refuse(`request already has its outcome`);
|
||||
const t = { outcome: a.outcome, status: a.status, comment: a.comment, detail: a.detail };
|
||||
const state = outcomeState(att, t);
|
||||
const comment = state === "posted" ? (att.comment ?? t.comment) : null;
|
||||
@@ -678,7 +678,7 @@ function applyReview(rows, row, entry) {
|
||||
else requirePriv(by, "abandon a review request");
|
||||
const found = findAttempt(row, a.attempt);
|
||||
const att = found.attempt;
|
||||
if (!UNRESOLVED.has(att.state)) throw refuse(`request ${att.op} is ${att.state}; only a requesting, uncertain or conflict request can be ${resolve ? "resolved" : "abandoned"}`);
|
||||
if (!UNRESOLVED.has(att.state)) throw refuse(`request is ${att.state}; only a requesting, uncertain or conflict request can be ${resolve ? "resolved" : "abandoned"}`);
|
||||
const res = { verb: resolve ? "resolve" : "abandon", op: entry.op, by, at: entry.at, comment: resolve ? a.comment : null, reason: resolve ? null : a.reason };
|
||||
const state = resolve ? "posted" : "abandoned";
|
||||
const next = withAttempt(row, found, { ...att, state, comment: resolve ? a.comment : null, resolutions: [...att.resolutions, res] }, resolve ? {} : { duplicateRisk: true });
|
||||
@@ -689,11 +689,11 @@ function applyReview(rows, row, entry) {
|
||||
};
|
||||
}
|
||||
case "review-record": {
|
||||
if (!row.reviewers.includes(by) || by === row.owner) throw refuse(`only a listed reviewer other than the owner may record a verdict on row ${row.id} (reviewers: ${fmt(row.reviewers)})`);
|
||||
if (!row.reviewers.includes(by) || by === row.owner) throw refuse(`only a listed reviewer other than the owner may record a verdict on row ${row.id}`);
|
||||
if (!row.review) throw refuse(`row ${row.id} has no review round`);
|
||||
const cur = currentCommentRound(row, "review record");
|
||||
if (a.candidate !== cur.candidate.digest) throw refuse(`candidate ${a.candidate} is not round ${cur.n}'s candidate ${cur.candidate.digest}`);
|
||||
if (cur.receipts.some((r) => r.reviewer === by)) throw refuse(`${by} already recorded a verdict for row ${row.id} round ${cur.n}`);
|
||||
if (a.candidate !== cur.candidate.digest) throw refuse(`candidate is not round ${cur.n}'s candidate`);
|
||||
if (cur.receipts.some((r) => r.reviewer === by)) throw refuse(`reviewer already recorded a verdict for row ${row.id} round ${cur.n}`);
|
||||
const rec = { reviewer: by, op: entry.op, at: entry.at, verdict: a.verdict, comment: a.comment, candidate: a.candidate };
|
||||
const rounds = row.review.rounds.map((r) => (r.n === cur.n ? { ...r, receipts: [...r.receipts, rec] } : r));
|
||||
return {
|
||||
@@ -703,7 +703,7 @@ function applyReview(rows, row, entry) {
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw refuse(`unknown verb ${entry.verb}`);
|
||||
throw refuse(`unknown verb; expected a queue operation`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -759,14 +759,14 @@ function applyMove(rows, row, entry, resolved) {
|
||||
} else if (from === "queued" && to === "briefed") {
|
||||
requirePriv(by, "accept a brief (queued→briefed)");
|
||||
} else if (from === "briefed" && to === "in-progress") {
|
||||
if (by !== row.owner) throw refuse(`only the owner (${row.owner}) may start row ${row.id}`);
|
||||
if (by !== row.owner) throw refuse(`only the owner may start row ${row.id}`);
|
||||
const missing = afterSatisfied(rows, row);
|
||||
if (missing.length) throw refuse(`row ${row.id} waits on ${missing.join(", ")}`);
|
||||
next.claim = { seat: row.owner, op: entry.op };
|
||||
} else if (from === "in-progress" && to === "briefed") {
|
||||
throw refuse(`use \`queue release ${row.id}\` to give up a claim`);
|
||||
} else if (from === "in-progress" && to === "in-review") {
|
||||
if (row.claim === null || by !== row.claim.seat) throw refuse(`only the claimant (${row.claim?.seat ?? "nobody"}) may request review of row ${row.id}`);
|
||||
if (row.claim === null || by !== row.claim.seat) throw refuse(`only the claimant may request review of row ${row.id}`);
|
||||
if (candidate === null) throw refuse("in-progress→in-review needs --candidate <commit|manifest>");
|
||||
revIssue = reviewIssue(row, issue);
|
||||
cand = checkCandidate(resolved.candidate);
|
||||
@@ -797,7 +797,7 @@ function applyMove(rows, row, entry, resolved) {
|
||||
next.claim = null;
|
||||
} else if (from === "in-review" && to === "done") {
|
||||
if (row.gateOwner === "jason") throw refuse(`row ${row.id}'s gate is Jason's; it goes through waiting-on-jason`);
|
||||
if (by !== row.gateOwner && !isPriv(by)) throw refuse(`only the gate owner (${row.gateOwner}) or a privileged actor may close row ${row.id}`);
|
||||
if (by !== row.gateOwner && !isPriv(by)) throw refuse(`only the gate owner or a privileged actor may close row ${row.id}`);
|
||||
refuseUnresolved(row, "closing it");
|
||||
const cur = row.review?.rounds.at(-1);
|
||||
if (cur?.request === "comment") {
|
||||
@@ -808,7 +808,7 @@ function applyMove(rows, row, entry, resolved) {
|
||||
const ev = parseReviewEvidence(evidence);
|
||||
if (!cur) throw refuse(`row ${row.id} has no review round to cite`);
|
||||
if (ev.round !== cur.n) throw refuse(`evidence names round ${ev.round}; row ${row.id} is in round ${cur.n}`);
|
||||
if (ev.candidate !== cur.candidate.digest) throw refuse(`evidence candidate ${ev.candidate} is not round ${cur.n}'s candidate ${cur.candidate.digest}`);
|
||||
if (ev.candidate !== cur.candidate.digest) throw refuse(`evidence candidate is not round ${cur.n}'s candidate`);
|
||||
}
|
||||
round = cur.n;
|
||||
next.claim = null;
|
||||
@@ -834,7 +834,7 @@ function applySet(rows, row, entry, resolved) {
|
||||
switch (field) {
|
||||
case "piece": case "gate": case "reviewers": case "issues":
|
||||
requirePriv(by, `change ${field}`);
|
||||
if (field === "reviewers" && value.includes(row.owner)) throw refuse(`row ${row.id} owner ${row.owner} can't be a listed reviewer`);
|
||||
if (field === "reviewers" && value.includes(row.owner)) throw refuse(`row ${row.id} owner can't be a listed reviewer`);
|
||||
next[field] = value;
|
||||
// N10: closes follows the issues only if nobody narrowed it. A logged
|
||||
// narrowing keeps its intersection with the new issues.
|
||||
@@ -860,7 +860,7 @@ function applySet(rows, row, entry, resolved) {
|
||||
requirePriv(by, "re-pin a brief");
|
||||
next.brief = checkBrief(resolved.brief);
|
||||
if (next.brief.path !== parseBriefSpec(value).path || next.brief.anchor !== parseBriefSpec(value).anchor) throw refuse("resolved brief does not match the brief argument");
|
||||
if (row.brief !== null && next.brief.blob === row.brief.blob && sameJson(next.brief, row.brief)) throw refuse(`row ${row.id} brief is already pinned to ${row.brief.blob}`);
|
||||
if (row.brief !== null && next.brief.blob === row.brief.blob && sameJson(next.brief, row.brief)) throw refuse(`row ${row.id} brief is already pinned to that blob`);
|
||||
break;
|
||||
case "required":
|
||||
if (value) {
|
||||
@@ -877,7 +877,7 @@ function applySet(rows, row, entry, resolved) {
|
||||
throw refuse(`set cannot change ${field}`);
|
||||
}
|
||||
if (sameJson(next[key], row[key]) && (field !== "issues" || sameJson(next.closes, row.closes))) {
|
||||
throw refuse(`row ${row.id} ${key} is already ${fmt(row[key])}`);
|
||||
throw refuse(`row ${row.id} ${key} is already set to that value`);
|
||||
}
|
||||
const result = { row: row.id, field: key, from: row[key], to: next[key] };
|
||||
if (field === "issues") result.closes = { from: row.closes, to: next.closes, narrowed: !sameJson(row.closes, row.issues) };
|
||||
@@ -908,7 +908,7 @@ export function applyEntry(state, entry, resolved) {
|
||||
const id = highWater + 1;
|
||||
const owner = a.owner ?? by;
|
||||
const reviewers = a.reviewers ?? [];
|
||||
if (reviewers.includes(owner)) throw refuse(`row ${id} owner ${owner} can't be a listed reviewer`);
|
||||
if (reviewers.includes(owner)) throw refuse(`row ${id} owner can't be a listed reviewer`);
|
||||
highWater = id;
|
||||
const required = a.required ?? false;
|
||||
const row = {
|
||||
@@ -947,7 +947,7 @@ export function applyEntry(state, entry, resolved) {
|
||||
case "release": {
|
||||
const row = getRow(rows, a.id);
|
||||
if (row.state !== "in-progress") throw refuse(`row ${row.id} is ${row.state}; release applies to in-progress rows`);
|
||||
if (!(row.claim && by === row.claim.seat) && !isPriv(by)) throw refuse(`only the claimant (${row.claim?.seat}) or a privileged actor may release row ${row.id}`);
|
||||
if (!(row.claim && by === row.claim.seat) && !isPriv(by)) throw refuse(`only the claimant or a privileged actor may release row ${row.id}`);
|
||||
rows.set(row.id, touch({ ...row, state: "briefed", claim: null }, entry));
|
||||
result = { row: row.id, from: "in-progress", to: "briefed", receipt: receipt(entry, rev, `row ${row.id} in-progress→briefed`) };
|
||||
break;
|
||||
@@ -956,8 +956,8 @@ export function applyEntry(state, entry, resolved) {
|
||||
const row = getRow(rows, a.id);
|
||||
requirePriv(by, "assign a row");
|
||||
if (row.state === "done") throw refuse(`row ${row.id} is done; done rows never change`);
|
||||
if (row.owner === a.seat) throw refuse(`row ${row.id} is already owned by ${a.seat}`);
|
||||
if (row.reviewers.includes(a.seat)) throw refuse(`can't assign row ${row.id} to ${a.seat}: ${a.seat} is one of its reviewers`);
|
||||
if (row.owner === a.seat) throw refuse(`row ${row.id} is already owned by that seat`);
|
||||
if (row.reviewers.includes(a.seat)) throw refuse(`can't assign row ${row.id}: seat is one of its reviewers`);
|
||||
const claim = row.claim ? { seat: a.seat, op: entry.op } : null;
|
||||
rows.set(row.id, touch({ ...row, owner: a.seat, claim }, entry));
|
||||
result = { row: row.id, field: "owner", from: row.owner, to: a.seat, receipt: receipt(entry, rev, `row ${row.id} owner: ${row.owner}→${a.seat}`) };
|
||||
@@ -966,7 +966,7 @@ export function applyEntry(state, entry, resolved) {
|
||||
case "note": {
|
||||
const row = getRow(rows, a.id);
|
||||
if (row.state === "done" || row.state === "parked") throw refuse(`row ${row.id} is ${row.state}; notes are closed`);
|
||||
if (by !== row.owner && !row.reviewers.includes(by) && !isPriv(by)) throw refuse(`only the owner (${row.owner}), a listed reviewer or a privileged actor may note row ${row.id}`);
|
||||
if (by !== row.owner && !row.reviewers.includes(by) && !isPriv(by)) throw refuse(`only the owner, a listed reviewer or a privileged actor may note row ${row.id}`);
|
||||
// On a blocked row the note is the reason (8.7: "update the reason
|
||||
// with note"); elsewhere it is the row's note, and "" clears it.
|
||||
const field = row.state === "blocked" ? "blockedReason" : "note";
|
||||
@@ -1003,7 +1003,7 @@ export function applyEntry(state, entry, resolved) {
|
||||
case "genesis":
|
||||
throw refuse("genesis is only log[0]");
|
||||
default:
|
||||
throw refuse(`unknown verb ${entry.verb}`);
|
||||
throw refuse(`unknown verb; expected a queue operation`);
|
||||
}
|
||||
validateRows([...rows.values()]);
|
||||
return { state: { rows, highWater, retired: state.retired, revision: rev }, result };
|
||||
@@ -1029,11 +1029,11 @@ export function parseMigrationMap(text) {
|
||||
keysExactly(map, ["rows", "retired", "highWater"], "migration map");
|
||||
if (!Array.isArray(map.rows)) throw refuse("migration map rows must be a list");
|
||||
map.rows.forEach((r) => {
|
||||
keysExactly(r, MAP_ROW_KEYS, `migration map row ${r?.id}`);
|
||||
if (r.brief !== null) keysExactly(r.brief, ["path", "anchor"], `migration map row ${r.id} brief`);
|
||||
keysExactly(r, MAP_ROW_KEYS, `migration map row`);
|
||||
if (r.brief !== null) keysExactly(r.brief, ["path", "anchor"], `migration map row brief`);
|
||||
// The rule add, set and assign apply. Here, not in checkGenesis, so a
|
||||
// log written before the rule still replays.
|
||||
if (Array.isArray(r.reviewers) && r.reviewers.includes(r.owner)) throw refuse(`migration map row ${r.id} owner ${r.owner} can't be a listed reviewer`);
|
||||
if (Array.isArray(r.reviewers) && r.reviewers.includes(r.owner)) throw refuse(`migration map row owner can't be a listed reviewer`);
|
||||
});
|
||||
if (!Array.isArray(map.retired)) throw refuse("migration map retired must be a list");
|
||||
map.retired.forEach((id) => checkId(id, "retired id"));
|
||||
@@ -1102,8 +1102,8 @@ export function rowsArray(state) {
|
||||
|
||||
function checkEntryShape(e, i) {
|
||||
keysExactly(e, ENTRY_KEYS, `log entry ${i}`);
|
||||
if (e.rev !== i) throw refuse(`log entry ${i} has rev ${e.rev}`);
|
||||
if (!VERBS.includes(e.verb)) throw refuse(`log entry ${i} verb ${JSON.stringify(e.verb)} is unknown`);
|
||||
if (e.rev !== i) throw refuse(`log entry ${i} has rev that must equal its index`);
|
||||
if (!VERBS.includes(e.verb)) throw refuse(`log entry ${i} verb is unknown`);
|
||||
if (e.verb === "review-outcome") {
|
||||
if (typeof e.op !== "string" || !LOG_OP_RE.test(e.op) || !isObj(e.args) || e.op !== `${e.args.attempt}.outcome`) throw refuse(`log entry ${i} op must be its attempt's op plus .outcome`);
|
||||
} else {
|
||||
@@ -1111,7 +1111,7 @@ function checkEntryShape(e, i) {
|
||||
}
|
||||
checkName(e.by, `log entry ${i} by`);
|
||||
checkTime(e.at, `log entry ${i} at`);
|
||||
if (!Number.isInteger(e.semantics) || e.semantics < 1 || e.semantics > SEMANTICS) throw refuse(`log entry ${i} semantics ${e.semantics} is not 1 to ${SEMANTICS}`);
|
||||
if (!Number.isInteger(e.semantics) || e.semantics < 1 || e.semantics > SEMANTICS) throw refuse(`log entry ${i} semantics is not 1 to ${SEMANTICS}`);
|
||||
if (REVIEW_VERBS.includes(e.verb) && e.semantics < 2) throw refuse(`log entry ${i} ${e.verb} needs semantics 2`);
|
||||
if (typeof e.viewSha !== "string" || !SHA256_RE.test(e.viewSha)) throw refuse(`log entry ${i} viewSha is not a SHA-256`);
|
||||
}
|
||||
@@ -1129,16 +1129,16 @@ export function loadDoc(bytes) {
|
||||
try { doc = JSON.parse(text); } catch { throw refuse(`queue.json is not JSON`); }
|
||||
keysExactly(doc, DOC_KEYS, "queue.json");
|
||||
if (serialize(doc) !== text) throw refuse("queue.json does not re-serialize byte for byte (a hand edit?)");
|
||||
if (doc.version !== VERSION) throw refuse(`queue.json version ${doc.version} is not ${VERSION}`);
|
||||
if (doc.version !== VERSION) throw refuse(`queue.json version is not ${VERSION}`);
|
||||
if (typeof doc.canonicalRoot !== "string" || !doc.canonicalRoot.startsWith("/")) throw refuse("canonicalRoot must be an absolute path");
|
||||
if (!Array.isArray(doc.rows) || !Array.isArray(doc.log) || doc.log.length === 0) throw refuse("queue.json needs rows and a log starting at genesis");
|
||||
doc.log.forEach(checkEntryShape);
|
||||
const ops = new Set();
|
||||
for (const e of doc.log) {
|
||||
if (ops.has(e.op)) throw refuse(`op id ${e.op} appears twice in the log`);
|
||||
if (ops.has(e.op)) throw refuse(`op id appears twice in the log`);
|
||||
ops.add(e.op);
|
||||
}
|
||||
if (doc.revision !== doc.log.length - 1) throw refuse(`revision ${doc.revision} is not the last rev ${doc.log.length - 1}`);
|
||||
if (doc.revision !== doc.log.length - 1) throw refuse(`revision is not the last rev ${doc.log.length - 1}`);
|
||||
const state = replay(doc.log);
|
||||
if (!sameJson(rowsArray(state), doc.rows)) throw refuse("rows do not equal the replay of the log (a hand edit?)");
|
||||
if (doc.canonicalRoot !== doc.log[0].args.root) throw refuse("canonicalRoot differs from the genesis root");
|
||||
@@ -1158,10 +1158,10 @@ export function replay(log) {
|
||||
try {
|
||||
out = applyEntry(state, e, resolvedFromResult(e.verb, e.args, e.result));
|
||||
} catch (err) {
|
||||
if (err instanceof QueueError) throw refuse(`log entry ${i} (${e.op}) does not replay: ${err.message}`);
|
||||
if (err instanceof QueueError) throw refuse(`log entry ${i} does not replay: ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
if (!sameJson(out.result, e.result)) throw refuse(`log entry ${i} (${e.op}) result differs from its replay`);
|
||||
if (!sameJson(out.result, e.result)) throw refuse(`log entry ${i} result differs from its replay`);
|
||||
state = out.state;
|
||||
if (e.viewSha !== sha256(render(rowsArray(state), i))) throw refuse(`log entry ${i} viewSha is not the render of its rows`);
|
||||
}
|
||||
|
||||
@@ -119,8 +119,8 @@ test("add: defaults for an ordinary seat, privileged extras, refusals", () => {
|
||||
refused(() => step(d, "add", { ...add, owner: "rocko" }, "dewey", { brief: brief() }), /only a privileged actor may set owner/);
|
||||
refused(() => step(d, "add", { ...add, reviewers: ["rocko"] }, "dewey", { brief: brief() }), /reviewers/);
|
||||
refused(() => step(d, "add", { ...add, required: true }, "dewey", { brief: brief() }), /required/);
|
||||
refused(() => step(d, "add", { ...add, owner: "rocko", reviewers: ["rocko"] }, "sage", { brief: brief() }), /row 12 owner rocko can't be a listed reviewer/);
|
||||
refused(() => step(d, "add", { ...add, reviewers: ["sage"] }, "sage", { brief: brief() }), /row 12 owner sage can't be a listed reviewer/);
|
||||
refused(() => step(d, "add", { ...add, owner: "rocko", reviewers: ["rocko"] }, "sage", { brief: brief() }), /row 12 owner can't be a listed reviewer/);
|
||||
refused(() => step(d, "add", { ...add, reviewers: ["sage"] }, "sage", { brief: brief() }), /row 12 owner can't be a listed reviewer/);
|
||||
const d2 = step(d, "add", { ...add, owner: "rocko", gateOwner: "filbert", after: [{ id: 9, when: "done" }], reviewers: ["dewey"], required: true }, "sage", { brief: brief() });
|
||||
const r2 = row(d2, 12);
|
||||
assert.deepEqual([r2.owner, r2.gateOwner, r2.required, r2.after], ["rocko", "filbert", true, [{ id: 9, when: "done" }]]);
|
||||
@@ -134,7 +134,7 @@ test("matrix: queued→briefed privileged; briefed→in-progress owner with afte
|
||||
let d = step(genesisDoc(), "add", { piece: "N", gate: "g", brief: "docs/plans/brief-b.md#Queue" }, "dewey", { brief: brief() });
|
||||
refused(() => step(d, "move", mv(12, "briefed"), "dewey"), /privileged/);
|
||||
d = step(d, "move", mv(12, "briefed"), "sage");
|
||||
refused(() => step(d, "move", mv(12, "in-progress"), "sage"), /only the owner \(dewey\) may start/);
|
||||
refused(() => step(d, "move", mv(12, "in-progress"), "sage"), /only the owner may start/);
|
||||
d = step(d, "move", mv(12, "in-progress"), "dewey");
|
||||
assert.deepEqual(row(d, 12).claim.seat, "dewey");
|
||||
refused(() => step(d, "move", mv(12, "in-progress"), "dewey"), /not a transition/);
|
||||
@@ -158,7 +158,7 @@ test("matrix: release, review round, changes requested and waiting-on-jason", ()
|
||||
assert.deepEqual(Object.keys(rv), ["rounds"]);
|
||||
assert.deepEqual(rv.rounds.map((r) => [r.n, r.issue, r.request, r.candidate.digest]), [[1, 1508, "none", CAND.digest]]);
|
||||
assert.match(d.log.at(-1).result.receipt, /in-progress→in-review round 1 on #1508$/);
|
||||
refused(() => step(d, "move", mv(9, "in-progress"), "dewey"), /claimed by darkwing/);
|
||||
refused(() => step(d, "move", mv(9, "in-progress"), "dewey"), /claimed/);
|
||||
d = step(d, "move", mv(9, "in-progress"), "darkwing");
|
||||
assert.equal(row(d, 9).claim.seat, "darkwing");
|
||||
d = step(d, "move", mv(9, "in-review", { candidate: "y" }), "darkwing", { candidate: { kind: "commit", digest: BLOB2, text: null } });
|
||||
@@ -182,7 +182,7 @@ test("matrix J5: in-review→done by the gate owner with evidence naming the cur
|
||||
refused(() => step(d, "move", mv(9, "done", { evidence: `comment=4242,candidate=${CAND.digest}` }), "filbert"), /round=<n>/);
|
||||
refused(() => step(d, "move", mv(9, "done", { evidence: `comment=1,round=1,candidate=${"d".repeat(64)}` }), "filbert"), /is not round 1's candidate/);
|
||||
refused(() => step(d, "move", mv(9, "done", { evidence: `comment=4242,round=2,candidate=${CAND.digest}` }), "filbert"), /evidence names round 2; row 9 is in round 1/);
|
||||
refused(() => step(d, "move", mv(9, "done", { evidence: ev }), "rocko"), /only the gate owner \(filbert\)/);
|
||||
refused(() => step(d, "move", mv(9, "done", { evidence: ev }), "rocko"), /only the gate owner/);
|
||||
const done = step(d, "move", mv(9, "done", { evidence: ev }), "filbert");
|
||||
assert.equal(row(done, 9).state, "done");
|
||||
step(d, "move", mv(9, "done", { evidence: ev }), "sage");
|
||||
@@ -357,7 +357,7 @@ test("matrix R1: every state × target × actor class matches 8.7, gate owner ja
|
||||
test("matrix: blocked keeps the claim and returns only to previousState", () => {
|
||||
let d = row9Started();
|
||||
refused(() => step(d, "move", mv(9, "blocked"), "darkwing"), /needs --reason/);
|
||||
refused(() => step(d, "move", mv(9, "blocked", { reason: "x" }), "rocko"), /claimed by darkwing/);
|
||||
refused(() => step(d, "move", mv(9, "blocked", { reason: "x" }), "rocko"), /claimed/);
|
||||
d = step(d, "move", mv(9, "blocked", { reason: "waiting on git" }), "darkwing");
|
||||
const r = row(d, 9);
|
||||
assert.deepEqual([r.state, r.previousState, r.blockedReason, r.claim.seat], ["blocked", "in-progress", "waiting on git", "darkwing"]);
|
||||
@@ -391,8 +391,8 @@ test("field edits: who may change what", () => {
|
||||
const set = (id, field, value, reason = null) => ({ id, field, value, reason });
|
||||
refused(() => step(d, "set", set(11, "piece", "x"), "dewey"), /privileged/);
|
||||
assert.match(step(d, "set", set(11, "piece", "Renamed"), "sage").log.at(-1).result.receipt, /row 11 piece: Brief template→Renamed$/);
|
||||
refused(() => step(d, "set", set(9, "reviewers", ["darkwing"]), "sage"), /row 9 owner darkwing can't be a listed reviewer/);
|
||||
refused(() => step(d, "set", set(9, "reviewers", ["filbert", "darkwing"]), "sage"), /row 9 owner darkwing can't be a listed reviewer/);
|
||||
refused(() => step(d, "set", set(9, "reviewers", ["darkwing"]), "sage"), /row 9 owner can't be a listed reviewer/);
|
||||
refused(() => step(d, "set", set(9, "reviewers", ["filbert", "darkwing"]), "sage"), /row 9 owner can't be a listed reviewer/);
|
||||
assert.deepEqual(row(step(d, "set", set(9, "reviewers", ["dewey"]), "sage"), 9).reviewers, ["dewey"]);
|
||||
refused(() => step(d, "set", set(9, "after", []), "sage"), /only jason may change after on a required row/);
|
||||
step(d, "set", set(9, "after", []), "jason");
|
||||
@@ -508,7 +508,7 @@ test("every accepted text renders to nine cells on every row (N8)", () => {
|
||||
|
||||
test("genesis: the map refuses an owner among its row's reviewers; replay doesn't (2026-10-04)", () => {
|
||||
const self = MAP_ROWS.map((m) => (m.id === 9 ? { ...m, reviewers: ["darkwing", "filbert"] } : m));
|
||||
refused(() => parseMigrationMap(mapText(self)), /migration map row 9 owner darkwing can't be a listed reviewer/);
|
||||
refused(() => parseMigrationMap(mapText(self)), /migration map row owner can't be a listed reviewer/);
|
||||
// A log from before the rule still loads: the check is on the map, not in replay.
|
||||
const old = genesisDoc(self, "genesis-op-0001", { rows: self, retired: [7], highWater: 11 });
|
||||
assert.deepEqual(row(loadDoc(Buffer.from(serialize(old))).doc, 9).reviewers, ["darkwing", "filbert"]);
|
||||
@@ -522,8 +522,8 @@ test("times and dates must be calendar values, not just the shape (2026-10-04)",
|
||||
validateRow({ ...r, createdAt: v });
|
||||
}
|
||||
for (const v of ["2026-13-01", "2026-00-10", "2026-01-32", "2026-02-30", "2027-02-29", "2026-04-31"]) {
|
||||
refused(() => validateRow({ ...r, requiredSince: v }), new RegExp(`row 9 requiredSince is not a calendar date: "${v}"`));
|
||||
refused(() => validateRow({ ...r, createdAt: v }), new RegExp(`row 9 createdAt is not a calendar date: "${v}"`));
|
||||
refused(() => validateRow({ ...r, requiredSince: v }), /row 9 requiredSince is not a calendar date/);
|
||||
refused(() => validateRow({ ...r, createdAt: v }), /row 9 createdAt is not a calendar date/);
|
||||
}
|
||||
// ISO times: the same days, and times past the clock. V8 reads 2026-02-30
|
||||
// as March 2 and 24:00 as the next day; only the round trip catches those.
|
||||
@@ -533,15 +533,15 @@ test("times and dates must be calendar values, not just the shape (2026-10-04)",
|
||||
}
|
||||
const T = "T12:00:00.000Z";
|
||||
for (const v of [`2026-13-01${T}`, `2026-01-32${T}`, `2026-02-30${T}`, `2027-02-29${T}`, "2026-01-01T24:00:00.000Z", "2026-01-01T23:60:00.000Z", "2026-01-01T23:59:60.000Z"]) {
|
||||
refused(() => validateRow({ ...r, updatedAt: v }), new RegExp(`row 9 updatedAt is not a calendar time: "${v}"`));
|
||||
refused(() => validateRow({ ...r, requiredSince: v }), new RegExp(`row 9 requiredSince is not a calendar time: "${v}"`));
|
||||
refused(() => validateRow({ ...r, updatedAt: v }), /row 9 updatedAt is not a calendar time/);
|
||||
refused(() => validateRow({ ...r, requiredSince: v }), /row 9 requiredSince is not a calendar time/);
|
||||
}
|
||||
// A shape error keeps its own message.
|
||||
refused(() => validateRow({ ...r, updatedAt: "2026-01-01" }), /row 9 updatedAt must be an ISO time: "2026-01-01"/);
|
||||
refused(() => validateRow({ ...r, updatedAt: "2026-01-01" }), /row 9 updatedAt must be an ISO time/);
|
||||
// A log entry's time goes through the same check on replay.
|
||||
const d = step(genesisDoc(), "note", { id: 9, text: "x" }, "darkwing");
|
||||
d.log[1].at = `2026-02-30${T}`;
|
||||
refused(() => loadDoc(Buffer.from(serialize(d))), /log entry 1 at is not a calendar time: "2026-02-30T12:00:00.000Z"/);
|
||||
refused(() => loadDoc(Buffer.from(serialize(d))), /log entry 1 at is not a calendar time/);
|
||||
});
|
||||
|
||||
test("replay holds every op id to the caller's rule (N11)", () => {
|
||||
@@ -550,10 +550,10 @@ test("replay holds every op id to the caller's rule (N11)", () => {
|
||||
// 72 characters replay; 73 to 80 no longer do.
|
||||
load(step(d, "note", { id: 9, text: "x" }, "darkwing", {}, "a".repeat(72)));
|
||||
for (const n of [73, 80]) {
|
||||
refused(() => load(step(d, "note", { id: 9, text: "x" }, "darkwing", {}, "a".repeat(n))), /log entry 1 op .* must match/);
|
||||
refused(() => load(step(d, "note", { id: 9, text: "x" }, "darkwing", {}, "a".repeat(n))), /log entry 1 op must match/);
|
||||
}
|
||||
// .outcome is reserved on every verb, accept-history included.
|
||||
refused(() => load(step(d, "accept-history", { reason: "r" }, "sage", { oldWitness: null }, "accept-history-1.outcome")), /log entry 1 op .* ends in \.outcome/);
|
||||
refused(() => load(step(d, "accept-history", { reason: "r" }, "sage", { oldWitness: null }, "accept-history-1.outcome")), /log entry 1 op ends in \.outcome/);
|
||||
refused(() => load(step(d, "note", { id: 9, text: "x" }, "darkwing", {}, "note-row9.outcome")), /\.outcome, which is reserved/);
|
||||
// The genesis op is checked too.
|
||||
load(genesisDoc(MAP_ROWS, "genesis-ok-0001"));
|
||||
@@ -586,11 +586,11 @@ test("note: owner, listed reviewer or privileged; empty clears", () => {
|
||||
test("assign moves the claim with the owner; done clears it", () => {
|
||||
let d = row9Started();
|
||||
refused(() => step(d, "assign", { id: 9, seat: "dewey" }, "darkwing"), /privileged/);
|
||||
refused(() => step(d, "assign", { id: 9, seat: "filbert" }, "sage"), /can't assign row 9 to filbert: filbert is one of its reviewers/);
|
||||
refused(() => step(d, "assign", { id: 9, seat: "filbert" }, "sage"), /can't assign row 9: seat is one of its reviewers/);
|
||||
d = step(d, "assign", { id: 9, seat: "dewey" }, "sage", {}, "assign-row-9-dewey");
|
||||
assert.deepEqual(row(d, 9).claim, { seat: "dewey", op: "assign-row-9-dewey" });
|
||||
assert.match(d.log.at(-1).result.receipt, /owner: darkwing→dewey$/);
|
||||
refused(() => step(d, "move", mv(9, "in-review", { candidate: "x" }), "darkwing", { candidate: CAND }), /only the claimant \(dewey\)/);
|
||||
refused(() => step(d, "move", mv(9, "in-review", { candidate: "x" }), "darkwing", { candidate: CAND }), /only the claimant/);
|
||||
const unclaimed = step(genesisDoc(), "assign", { id: 11, seat: "rocko" }, "sage");
|
||||
assert.equal(row(unclaimed, 11).claim, null);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
function scratch(t) { const root = mkdtempSync(join(tmpdir(), 'r4-')); t.after(() => rmSync(root, { recursive: true, force: true })); return root; }
|
||||
function safe(label, Type, props = {}, omitted = secret) { return error => {
|
||||
assert.ok(error instanceof Type, error.stack);
|
||||
for (const [key, value] of Object.entries(props)) assert.equal(error[key], value);
|
||||
assert.ok(error.message.includes(label), error.message);
|
||||
assert.doesNotMatch(error.message + error.stack, /sk-test|NOT-A-SECRET|Unexpected token/);
|
||||
assert.ok(!error.message.includes(omitted), error.message);
|
||||
assert.equal(error.cause, undefined);
|
||||
return true;
|
||||
}; }
|
||||
import { checkCallerOpId, checkName, checkRepoPath, parseManifest, validateRow, loadDoc, serialize } from '../src/queue.mjs';
|
||||
import { QueueError } from '../src/errors.mjs';
|
||||
for (const [field, fn] of [
|
||||
['op', () => checkCallerOpId(secret + '/', 'op')],
|
||||
['op', () => checkCallerOpId(secret.toLowerCase() + '.outcome', 'op')],
|
||||
['owner', () => checkName(secret, 'owner')],
|
||||
['path', () => checkRepoPath(secret + '/', 'path')],
|
||||
['manifest', () => parseManifest(secret + '\n')],
|
||||
['row', () => validateRow({ id: secret })],
|
||||
['version', () => loadDoc(Buffer.from(serialize({ version: secret, canonicalRoot: '/x', revision: 0, rows: [], log: [] })))],
|
||||
]) test(`queue ${field} semantic refusal omits the value`, () => assert.throws(fn, safe(field, QueueError, { code: 2 }, secret.toLowerCase())));
|
||||
import { checkTransport, parseBriefSpec, genesisRows, parseMigrationMap, render, sha256, genesisReceipt } from '../src/queue.mjs';
|
||||
import { MAP_ROWS, mapText } from './helpers.mjs';
|
||||
const at = '2026-11-10T00:00:00.000Z';
|
||||
function doc() {
|
||||
const map = parseMigrationMap(mapText()), rows = genesisRows(map, new Map(map.rows.filter(r => r.brief).map(r => [r.id, 'a'.repeat(40)])), 'genesis-op-0001', at, 'sage');
|
||||
const entry = { rev: 0, op: 'genesis-op-0001', verb: 'genesis', args: { root: '/x', branch: 'refactor', map: 'docs/map.md' }, by: 'sage', at, semantics: 1,
|
||||
result: { mapBlob: 'b'.repeat(40), highWater: map.highWater, retired: map.retired, rows, legacyView: 'legacy\n', receipt: genesisReceipt('genesis-op-0001', rows.length) }, viewSha: sha256(render(rows, 0)) };
|
||||
return { version: 1, canonicalRoot: '/x', revision: 0, rows, log: [entry] };
|
||||
}
|
||||
for (const [field, edit, value = secret] of [
|
||||
['id', r => { r.id = secret; }], ['state', r => { r.state = secret; }],
|
||||
['createdAt', r => { r.createdAt = secret; }],
|
||||
['createdAt', r => { r.createdAt = '2026-02-30'; }, '2026-02-30'],
|
||||
['claim seat', r => { r.claim = { seat: secret, op: 'claimed-op-0001' }; }],
|
||||
]) test(`row ${field} omits semantic value ${value === secret ? 'sentinel' : 'calendar control'}`, () => {
|
||||
const r = doc().rows[0]; edit(r); assert.throws(() => validateRow(r), safe(field, QueueError, { code: 2 }, value));
|
||||
});
|
||||
for (const [field, edit] of [
|
||||
['rev', d => { d.log[0].rev = secret; }], ['verb', d => { d.log[0].verb = secret; }],
|
||||
['semantics', d => { d.log[0].semantics = secret; }], ['revision', d => { d.revision = secret; }],
|
||||
]) test(`queue ${field} omits semantic value from the log/document`, () => {
|
||||
const d = doc(); edit(d); assert.throws(() => loadDoc(Buffer.from(serialize(d))), safe(field, QueueError, { code: 2 }));
|
||||
});
|
||||
test('transport comment omits its value', () => assert.throws(() => checkTransport({ outcome: 'posted', status: 201, comment: secret, detail: 'fixture' }), safe('comment', QueueError, { code: 2 })));
|
||||
test('brief omits its unparseable spec', () => assert.throws(() => parseBriefSpec(secret), safe('brief', QueueError, { code: 2 })));
|
||||
test('manifest duplicate path omits the value', () => assert.throws(() => parseManifest(`${'a'.repeat(64)} ${secret}\n`.repeat(2)), safe('manifest', QueueError, { code: 2 })));
|
||||
|
||||
import { canonArgs, applyEntry, replay } from '../src/queue.mjs';
|
||||
const seatValue = secret.toLowerCase(), digestValue = 'a'.repeat(40);
|
||||
function reviewRow(row, state = 'requesting') {
|
||||
row.state = 'in-review';
|
||||
const posted = state === 'posted';
|
||||
row.review = { rounds: [{ n: 1, op: seatValue, by: 'darkwing', at, issue: 1511,
|
||||
candidate: { kind: 'commit', digest: digestValue, text: null }, request: 'comment',
|
||||
attempts: [{ op: seatValue, by: 'darkwing', at, state, comment: posted ? 1 : null,
|
||||
transport: posted ? { outcome: 'posted', status: 201, comment: 1, detail: 'fixture' } : null, resolutions: [] }],
|
||||
duplicateRisk: false, receipts: [] }] };
|
||||
}
|
||||
function operation(verb, args, edit = () => {}, options = {}) {
|
||||
const state = replay(doc().log), row = state.rows.get(6);
|
||||
edit(row);
|
||||
if (!options.unchecked) validateRow(row);
|
||||
const by = options.by ?? 'sage', op = options.op ?? 'operation-op-0001';
|
||||
const entry = { verb, args: options.raw ? args : canonArgs(verb, { id: 6, ...args }), by, op, at, semantics: 2 };
|
||||
return applyEntry(state, entry, options.resolved ?? {});
|
||||
}
|
||||
const ownerValue = row => { row.owner = seatValue; if (row.claim) row.claim.seat = seatValue; };
|
||||
for (const [name, field, fn, omitted = seatValue] of [
|
||||
['canonical duplicate reviewer', 'repeated value', () => canonArgs('set', { id: 6, field: 'reviewers', value: [seatValue, seatValue] })],
|
||||
['canonical state', 'state', () => canonArgs('move', { id: 6, to: secret }), secret],
|
||||
['canonical verb', 'verb', () => canonArgs(secret, {}), secret],
|
||||
['claimed owner authority', 'owner', () => operation('move', { to: 'blocked', reason: 'pause' }, ownerValue, { by: 'outsider' })],
|
||||
['start owner', 'owner', () => operation('move', { to: 'in-progress' }, r => { ownerValue(r); r.state = 'briefed'; r.claim = null; }, { by: 'outsider' })],
|
||||
['review claimant', 'claimant', () => operation('move', { to: 'in-review', candidate: 'HEAD' }, ownerValue, { by: 'outsider' })],
|
||||
['close gate owner', 'gate owner', () => operation('move', { to: 'done' }, r => { reviewRow(r, 'posted'); r.gateOwner = seatValue; }, { by: 'outsider' })],
|
||||
['set owner reviewer', 'owner', () => operation('set', { field: 'reviewers', value: [seatValue] }, ownerValue)],
|
||||
['set stored piece', 'piece', () => operation('set', { field: 'piece', value: secret }, r => { r.piece = secret; }), secret],
|
||||
['set pinned brief', 'brief', () => { const r = doc().rows.find(r => r.id === 6), brief = { ...r.brief, blob: digestValue };
|
||||
return operation('set', { field: 'brief', value: `${brief.path}#${brief.anchor}` }, r => { r.brief = brief; }, { resolved: { brief } }); }, digestValue],
|
||||
['release claimant', 'claimant', () => operation('release', {}, ownerValue, { by: 'outsider' })],
|
||||
['assign existing owner', 'owned', () => operation('assign', { seat: seatValue }, ownerValue)],
|
||||
['assign reviewer', 'reviewers', () => operation('assign', { seat: seatValue }, r => { r.reviewers = [seatValue]; })],
|
||||
['note owner', 'owner', () => operation('note', { text: 'note' }, ownerValue, { by: 'outsider' })],
|
||||
['add owner reviewer', 'owner', () => operation('add', { piece: 'piece', gate: 'gate', brief: 'docs/brief.md#Brief', owner: seatValue, reviewers: [seatValue] }, () => {},
|
||||
{ resolved: { brief: { path: 'docs/brief.md', anchor: 'Brief', blob: digestValue } } })],
|
||||
['unresolved request', 'unresolved review request', () => operation('review-request', {}, r => reviewRow(r))],
|
||||
['missing approval', 'approval', () => operation('move', { to: 'done' }, r => { reviewRow(r, 'posted'); r.gateOwner = 'sage'; r.reviewers = [seatValue]; })],
|
||||
['missing attempt without round', 'review request', () => operation('review-resolve', { attempt: seatValue, comment: 1 })],
|
||||
['missing attempt in round', 'review request', () => operation('review-resolve', { attempt: seatValue + '-missing', comment: 1 }, r => reviewRow(r))],
|
||||
['posted request', 'posted request', () => operation('review-request', {}, r => reviewRow(r, 'posted'))],
|
||||
['outcome op', 'outcome', () => operation('review-outcome', { attempt: seatValue, outcome: 'posted', status: 201, comment: 1, detail: 'fixture' }, r => reviewRow(r))],
|
||||
['outcome actor', 'outcome', () => operation('review-outcome', { attempt: seatValue, outcome: 'posted', status: 201, comment: 1, detail: 'fixture' }, r => { reviewRow(r); r.review.rounds[0].attempts[0].by = seatValue; },
|
||||
{ by: 'outsider', op: seatValue + '.outcome' })],
|
||||
['duplicate outcome', 'outcome', () => operation('review-outcome', { attempt: seatValue, outcome: 'posted', status: 201, comment: 1, detail: 'fixture' }, r => reviewRow(r, 'posted'),
|
||||
{ by: 'darkwing', op: seatValue + '.outcome' })],
|
||||
['settled resolution', 'request', () => operation('review-resolve', { attempt: seatValue, comment: 1 }, r => reviewRow(r, 'posted'))],
|
||||
['reviewer list', 'reviewer', () => operation('review-record', { verdict: 'approve', comment: 1, candidate: digestValue }, r => { reviewRow(r, 'posted'); r.reviewers = [seatValue]; }, { by: 'outsider' })],
|
||||
['candidate mismatch', 'candidate', () => operation('review-record', { verdict: 'approve', comment: 1, candidate: 'b'.repeat(40) }, r => reviewRow(r, 'posted'), { by: 'filbert' }), digestValue],
|
||||
['duplicate verdict', 'verdict', () => operation('review-record', { verdict: 'approve', comment: 1, candidate: digestValue }, r => { reviewRow(r, 'posted'); r.reviewers = [seatValue];
|
||||
r.review.rounds[0].receipts = [{ reviewer: seatValue, op: 'receipt-op-0001', at, verdict: 'approve', comment: 1, candidate: digestValue }]; }, { by: seatValue })],
|
||||
['legacy candidate evidence', 'candidate', () => operation('move', { to: 'done', evidence: `comment=1,round=1,candidate=${'b'.repeat(40)}` }, r => {
|
||||
reviewRow(r); r.gateOwner = 'sage'; const round = r.review.rounds[0]; round.request = 'none'; delete round.attempts; delete round.duplicateRisk; delete round.receipts;
|
||||
}), digestValue],
|
||||
['direct unknown operation', 'verb', () => operation(secret, {}, () => {}, { raw: true }), secret],
|
||||
// outcomeState also defends direct callers with an inconsistent transport record.
|
||||
['inconsistent outcome record', 'outcome', () => operation('review-outcome', { attempt: seatValue, outcome: 'posted', status: 201, comment: 1, detail: 'fixture' }, r => reviewRow(r, 'failed'),
|
||||
{ by: 'darkwing', op: seatValue + '.outcome', unchecked: true })],
|
||||
]) test(`queue replay refusal ${name} omits stored values`, () => assert.throws(fn, safe(field, QueueError, { code: 2 }, omitted)));
|
||||
|
||||
test('migration map shape omits an unchecked row id', () => assert.throws(() => parseMigrationMap(mapText([{ id: secret }])), safe('migration map row', QueueError, { code: 2 })));
|
||||
test('migration map brief shape omits an unchecked row id', () => {
|
||||
const row = structuredClone(MAP_ROWS[1]); row.id = secret; row.brief = {};
|
||||
assert.throws(() => parseMigrationMap(mapText([row])), safe('brief', QueueError, { code: 2 }));
|
||||
});
|
||||
test('migration map duplicate owner omits unvalidated values', () => {
|
||||
const row = structuredClone(MAP_ROWS[1]); row.owner = secret; row.reviewers = [secret];
|
||||
assert.throws(() => parseMigrationMap(mapText([row])), safe('owner', QueueError, { code: 2 }));
|
||||
});
|
||||
test('BASE control: genesis expected claim already omits the stored op', () => {
|
||||
const d = doc(); d.log[0].op = seatValue; d.log[0].result.receipt = genesisReceipt(seatValue, d.rows.length);
|
||||
assert.throws(() => loadDoc(Buffer.from(serialize(d))), safe('claim', QueueError, { code: 2 }, seatValue));
|
||||
});
|
||||
@@ -115,7 +115,7 @@ test("a request posts once as the requester; a retry sends nothing", async (t) =
|
||||
const again = ok(s.run(move9(), "darkwing"), /\(already recorded at rev 3\)$/m);
|
||||
assert.match(again.err, /request review-9-00001 is posted \(row 9 round 1 on #1508\), comment 1000; nothing was sent again/);
|
||||
assert.equal(s.calls().length, 2);
|
||||
no(s.run(request("request-9-0002"), "darkwing"), 2, /round 1 already has a posted request \(review-9-00001, comment 1000\)/);
|
||||
no(s.run(request("request-9-0002"), "darkwing"), 2, /round 1 already has a posted request/);
|
||||
no(s.run(["review", "outcome", "9"], "darkwing"), 4, /review takes request, resolve, abandon, record or verify-commit/);
|
||||
throwsCode(() => s.mut({ verb: "review-outcome", op: "review-9-00001.outcome", args: { id: 9, attempt: "review-9-00001", outcome: "failed", status: 403, comment: null, detail: "x" }, by: "darkwing" }), 2, /records its own outcome/);
|
||||
});
|
||||
@@ -238,10 +238,10 @@ test("an unresolved request blocks a new request, a new round, waiting-on-jason
|
||||
no(s.run(move9(), "darkwing"), 3, /^Look on #1508 for a comment carrying <!-- mosaic-queue-op: review-9-00001 -->\.$/m);
|
||||
s.rules([]);
|
||||
const head = s.head();
|
||||
no(s.run(request("request-9-0002"), "darkwing"), 2, /unresolved review request: review-9-00001 \(round 1, uncertain\)/);
|
||||
no(s.run(request("request-9-0002"), "darkwing"), 2, /unresolved review request/);
|
||||
ok(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "77", "--candidate", head, "--op", "record-9-filbert1"], "filbert"));
|
||||
no(s.run(["move", "9", "done", "--op", "done-9-000001"], "filbert"), 2, /unresolved review request: review-9-00001 \(round 1, uncertain\); resolve or abandon it before closing it/);
|
||||
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /unresolved review request: review-9-00001 \(round 1, uncertain\); resolve or abandon it before moving it to waiting-on-jason/);
|
||||
no(s.run(["move", "9", "done", "--op", "done-9-000001"], "filbert"), 2, /unresolved review request; resolve or abandon it before closing it/);
|
||||
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /unresolved review request; resolve or abandon it before moving it to waiting-on-jason/);
|
||||
assert.equal(s.posts().length, 1);
|
||||
|
||||
// A POST that lands after the row reached waiting-on-jason: abandoned,
|
||||
@@ -256,7 +256,7 @@ test("an unresolved request blocks a new request, a new round, waiting-on-jason
|
||||
};
|
||||
assert.equal(s3.mut({ verb: "move", op: "review-9-00001", args: { id: 9, to: "in-review", candidate: "HEAD" }, by: "darkwing" }, { hook }).code, 3);
|
||||
assert.deepEqual([s3.row(9).state, s3.round(9, 1).attempts[0].state], ["waiting-on-jason", "conflict"]);
|
||||
no(s3.run(["move", "9", "done", "--op", "done-9-000002"], "jason"), 2, /unresolved review request: review-9-00001 \(round 1, conflict\); resolve or abandon it before closing it/);
|
||||
no(s3.run(["move", "9", "done", "--op", "done-9-000002"], "jason"), 2, /unresolved review request; resolve or abandon it before closing it/);
|
||||
|
||||
// Back to in-progress is allowed; the next round is not.
|
||||
const s2 = await ready(t);
|
||||
@@ -264,7 +264,7 @@ test("an unresolved request blocks a new request, a new round, waiting-on-jason
|
||||
no(s2.run(move9(), "darkwing"), 3);
|
||||
s2.rules([]);
|
||||
ok(s2.run(["move", "9", "in-progress", "--op", "changes-9-0001"], "darkwing"));
|
||||
no(s2.run(["move", "9", "in-review", "--candidate", "HEAD", "--op", "review-9-00002"], "darkwing"), 2, /unresolved review request: review-9-00001 \(round 1, uncertain\); resolve or abandon it before a new round/);
|
||||
no(s2.run(["move", "9", "in-review", "--candidate", "HEAD", "--op", "review-9-00002"], "darkwing"), 2, /unresolved review request; resolve or abandon it before a new round/);
|
||||
assert.equal(s2.posts().length, 1);
|
||||
});
|
||||
|
||||
@@ -326,7 +326,7 @@ test("late outcomes: after an abandon, and after a resolve with the same or anot
|
||||
let a = s.round(9, 1).attempts[0];
|
||||
assert.deepEqual([a.state, a.comment, a.transport.comment, a.resolutions.map((x) => x.verb)], ["conflict", null, 1000, ["abandon"]]);
|
||||
assert.equal(s.round(9, 1).duplicateRisk, true);
|
||||
no(s.run(request("request-9-0002"), "darkwing"), 2, /unresolved review request: review-9-00001 \(round 1, conflict\)/);
|
||||
no(s.run(request("request-9-0002"), "darkwing"), 2, /unresolved review request/);
|
||||
ok(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "1000", "--op", "resolve-9-00001"], "darkwing"), /conflict→posted comment 1000$/m);
|
||||
a = s.round(9, 1).attempts[0];
|
||||
assert.deepEqual([a.state, a.comment, a.resolutions.map((x) => x.verb)], ["posted", 1000, ["abandon", "resolve"]]);
|
||||
@@ -407,7 +407,7 @@ test("resolve checks the comment: issue, markers, round, candidate and author",
|
||||
no(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "7001", "--op", "resolve-9-00001"], "darkwing"), 1, /failed or had no answer/);
|
||||
no(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "7001", "--op", "resolve-9-00001"], "darkwing", { MOSAIC_GITEA_CREDENTIAL_FILE: s.tokens.dewey }), 2, /cannot check comment 7001: .*not darkwing's token file/);
|
||||
const before = s.calls().length;
|
||||
no(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "7001", "--op", "resolve-9-00001"], "rocko"), 2, /^queue: row 9 is claimed by darkwing; only the owner \(darkwing\) or a privileged actor may resolve a request on row 9$/m);
|
||||
no(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "7001", "--op", "resolve-9-00001"], "rocko"), 2, /^queue: row 9 is claimed; only the owner or a privileged actor may resolve a request on row 9$/m);
|
||||
assert.equal(s.calls().length, before, "a refused actor makes no request");
|
||||
assert.equal(s.doc().revision, 4);
|
||||
s.rules([{ method: "GET", path: "comments/7001$", http: 200, body: good }]);
|
||||
@@ -463,9 +463,9 @@ test("request, changes, a new candidate, approval: every round pinned; no review
|
||||
no(s.run(["review", "record", "9", "--verdict", "maybe", "--comment", "88", "--candidate", c1, "--op", "record-9-filb01"], "filbert"), 2, /approve or changes/);
|
||||
no(s.run(["review", "record", "9", "--verdict", "changes", "--candidate", c1, "--op", "record-9-filb01"], "filbert"), 4, /--comment N is required/);
|
||||
ok(s.run(["review", "record", "9", "--verdict", "changes", "--comment", "88", "--candidate", c1, "--op", "record-9-filb01"], "filbert"), /row 9 round 1 changes by filbert \(comment 88\)$/m);
|
||||
no(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "89", "--candidate", c1, "--op", "record-9-filb02"], "filbert"), 2, /filbert already recorded a verdict for row 9 round 1/);
|
||||
no(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "89", "--candidate", c1, "--op", "record-9-filb02"], "filbert"), 2, /reviewer already recorded a verdict for row 9 round 1/);
|
||||
ok(cli(s.repo, ["next", "filbert"]), /^nothing$/m);
|
||||
no(s.run(["move", "9", "done", "--op", "done-9-000001"], "filbert"), 2, /round 1 has no approval recorded by filbert/);
|
||||
no(s.run(["move", "9", "done", "--op", "done-9-000001"], "filbert"), 2, /round 1 has no approval recorded by every listed reviewer/);
|
||||
ok(s.run(["move", "9", "in-progress", "--op", "changes-9-0001"], "darkwing"));
|
||||
no(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "89", "--candidate", c1, "--op", "record-9-filb02"], "filbert"), 2, /is in-progress; review record applies to in-review rows/);
|
||||
writeFileSync(join(s.repo.root, "packages/fix.mjs"), "export {};\n");
|
||||
@@ -474,10 +474,10 @@ test("request, changes, a new candidate, approval: every round pinned; no review
|
||||
const c2 = s.head();
|
||||
ok(s.run(move9("review-9-00002"), "darkwing"), /round 2 on #1508; request review-9-00002 requesting$/m);
|
||||
ok(cli(s.repo, ["next", "filbert"]), /^review row 9/m);
|
||||
no(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "89", "--candidate", c1, "--op", "record-9-filb02"], "filbert"), 2, new RegExp(`candidate ${c1} is not round 2's candidate ${c2}`));
|
||||
no(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "89", "--candidate", c1, "--op", "record-9-filb02"], "filbert"), 2, /candidate is not round 2's candidate/);
|
||||
ok(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "89", "--candidate", c2, "--op", "record-9-filb02"], "filbert"));
|
||||
no(s.run(["move", "9", "done", "--evidence", `comment=89,round=2,candidate=${c2}`, "--op", "done-9-000001"], "filbert"), 2, /closes on its recorded verdicts; drop --evidence/);
|
||||
no(s.run(["move", "9", "done", "--op", "done-9-000001"], "rocko"), 2, /only the gate owner \(filbert\) or a privileged actor/);
|
||||
no(s.run(["move", "9", "done", "--op", "done-9-000001"], "rocko"), 2, /only the gate owner or a privileged actor/);
|
||||
ok(s.run(["move", "9", "done", "--op", "done-9-000001"], "filbert"), /in-review→done round 2$/m);
|
||||
const rounds = s.row(9).review.rounds;
|
||||
assert.deepEqual(rounds.map((r) => [r.n, r.candidate.digest, r.attempts.map((a) => [a.op, a.state, a.comment]), r.receipts.map((x) => [x.reviewer, x.verdict, x.comment, x.candidate])]), [
|
||||
@@ -565,11 +565,11 @@ test("semantics: v1 entries replay as before; review entries need v2", async (t)
|
||||
const outcome = d.log.at(-1);
|
||||
assert.throws(() => queue.loadDoc(Buffer.from(queue.serialize({ ...d, log: [...d.log.slice(0, -1), { ...outcome, semantics: 1 }] }))), /review-outcome needs semantics 2/);
|
||||
assert.throws(() => queue.loadDoc(Buffer.from(queue.serialize({ ...d, log: [...d.log.slice(0, -1), { ...outcome, op: "review-9-00002.outcome" }] }))), /op must be its attempt's op plus \.outcome/);
|
||||
assert.throws(() => queue.loadDoc(Buffer.from(queue.serialize({ ...d, log: d.log.map((e, i) => (i === 1 ? { ...e, semantics: 3 } : e)) }))), /semantics 3 is not 1 to 2/);
|
||||
assert.throws(() => queue.loadDoc(Buffer.from(queue.serialize({ ...d, log: d.log.map((e, i) => (i === 1 ? { ...e, semantics: 3 } : e)) }))), /semantics is not 1 to 2/);
|
||||
// An attempt takes one outcome, even one that agrees with it.
|
||||
const full = queue.replay(d.log);
|
||||
const again = { ...outcome, rev: full.revision + 1 };
|
||||
assert.throws(() => queue.applyEntry(full, again, {}), /request review-9-00001 already has its outcome/);
|
||||
assert.throws(() => queue.applyEntry(full, again, {}), /request already has its outcome/);
|
||||
// review-outcome is the one entry a done row takes.
|
||||
const at = "2026-09-27T00:00:00.000Z";
|
||||
// The request was abandoned and the row closed before the POST's answer came.
|
||||
@@ -585,7 +585,7 @@ test("semantics: v1 entries replay as before; review entries need v2", async (t)
|
||||
assert.deepEqual([late.state.rows.get(9).state, a.state, a.transport.comment], ["done", "conflict", 1000]);
|
||||
const lateOk = (verb, op, args, by) => queue.applyEntry(st, entry(st, verb, op, args, by), {});
|
||||
assert.throws(() => lateOk("review-resolve", "resolve-9-00001", { id: 9, attempt: "review-9-00001", comment: 5 }), /done rows never change/);
|
||||
assert.throws(() => queue.applyEntry(mid, entry(mid, "review-outcome", "review-9-00001.outcome", { id: 9, attempt: "review-9-00001", outcome: "failed", status: 403, comment: null, detail: "x" }, "sage"), {}), /only darkwing, who made request review-9-00001, records its outcome/);
|
||||
assert.throws(() => queue.applyEntry(mid, entry(mid, "review-outcome", "review-9-00001.outcome", { id: 9, attempt: "review-9-00001", outcome: "failed", status: 403, comment: null, detail: "x" }, "sage"), {}), /only the actor who made the request records its outcome/);
|
||||
assert.throws(() => queue.canonArgs("review-outcome", { id: 9, attempt: "review-9-00001", outcome: "failed", status: 500, comment: null, detail: "x" }), /a failed outcome's status is one of 400, 401, 403, 404, 422/);
|
||||
assert.throws(() => queue.canonArgs("review-outcome", { id: 9, attempt: "review-9-00001", outcome: "posted", status: 201, comment: null, detail: "x" }), /posted exactly when HTTP 201 returned a comment id/);
|
||||
assert.throws(() => queue.canonArgs("review-outcome", { id: 9, attempt: "review-9-00001", outcome: "posted", status: 200, comment: 4, detail: "x" }), /posted exactly when HTTP 201/);
|
||||
@@ -593,7 +593,7 @@ test("semantics: v1 entries replay as before; review entries need v2", async (t)
|
||||
|
||||
test("set reviewers refuses the row's owner (2026-09-28)", async (t) => {
|
||||
const s = await ready(t);
|
||||
no(s.run(["set", "9", "reviewers", "filbert,darkwing", "--op", "reviewers-9-self"], "sage"), 2, /row 9 owner darkwing can't be a listed reviewer/);
|
||||
no(s.run(["set", "9", "reviewers", "filbert,darkwing", "--op", "reviewers-9-self"], "sage"), 2, /row 9 owner can't be a listed reviewer/);
|
||||
assert.deepEqual(s.row(9).reviewers, ["filbert"]);
|
||||
});
|
||||
|
||||
@@ -665,17 +665,17 @@ test("a Jason-gated row reaches waiting-on-jason only on every reviewer's approv
|
||||
ok(s.run(move9(), "darkwing"), /posted comment 1000$/m);
|
||||
assert.deepEqual(s.round(9, 1).receipts, []);
|
||||
no(s.run(["move", "9", "done", "--op", "done-9-000001"], "darkwing"), 2, /gate is Jason's; it goes through waiting-on-jason/);
|
||||
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /row 9 round 1 has no approval recorded by filbert, rocko$/m);
|
||||
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "sage"), 2, /no approval recorded by filbert, rocko$/m);
|
||||
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /row 9 round 1 has no approval recorded by every listed reviewer$/m);
|
||||
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "sage"), 2, /no approval recorded by every listed reviewer$/m);
|
||||
ok(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "88", "--candidate", head, "--op", "record-9-filb01"], "filbert"));
|
||||
ok(s.run(["review", "record", "9", "--verdict", "changes", "--comment", "89", "--candidate", head, "--op", "record-9-rock01"], "rocko"));
|
||||
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /no approval recorded by rocko$/m);
|
||||
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /no approval recorded by every listed reviewer$/m);
|
||||
// Changes go back to in-progress, as before.
|
||||
ok(s.run(["move", "9", "in-progress", "--op", "changes-9-0001"], "darkwing"));
|
||||
ok(s.run(move9("review-9-00002"), "darkwing"), /posted comment 1001$/m);
|
||||
ok(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "91", "--candidate", head, "--op", "record-9-rock02"], "rocko"));
|
||||
// Filbert's round 1 approval doesn't carry into round 2.
|
||||
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /row 9 round 2 has no approval recorded by filbert$/m);
|
||||
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /row 9 round 2 has no approval recorded by every listed reviewer$/m);
|
||||
ok(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "90", "--candidate", head, "--op", "record-9-filb02"], "filbert"));
|
||||
ok(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), /in-review→waiting-on-jason$/m);
|
||||
ok(s.run(["move", "9", "done", "--op", "done-9-000001"], "jason"), /waiting-on-jason→done$/m);
|
||||
|
||||
@@ -65,7 +65,7 @@ test("genesis: the map must be committed, well formed, with committed briefs and
|
||||
no(cli(noBrief, genesisArgs(noBrief), { by: "sage" }), 2, /only a done row may lack one/);
|
||||
// The owner-not-reviewer rule of add, set and assign (2026-10-04).
|
||||
const selfReview = scratchRepo(t, { map: mapText(MAP_ROWS.map((r) => (r.id === 9 ? { ...r, reviewers: ["darkwing", "filbert"] } : r))) });
|
||||
no(cli(selfReview, genesisArgs(selfReview), { by: "sage" }), 2, /migration map row 9 owner darkwing can't be a listed reviewer/);
|
||||
no(cli(selfReview, genesisArgs(selfReview), { by: "sage" }), 2, /migration map row owner can't be a listed reviewer/);
|
||||
for (const repo of [ghost, noBrief, selfReview]) {
|
||||
assert.throws(() => readFileSync(repo.queuePath));
|
||||
assert.throws(() => readFileSync(join(repo.gitDir, "mosaic-queue.head")));
|
||||
@@ -245,11 +245,11 @@ test("claims and add defaults through the CLI; candidates are manifests or reach
|
||||
test("add, set reviewers and assign refuse the row's owner as a reviewer", (t) => {
|
||||
const repo = ready(t);
|
||||
const before = doc(repo);
|
||||
no(cli(repo, ["add", "--op", "add-owner-reviewer", "--piece", "x", "--gate", "g", "--brief", "docs/plans/brief-b.md#Template", "--owner", "rocko", "--gate-owner", "filbert", "--reviewer", "rocko"], { by: "sage" }), 2, /row 12 owner rocko can't be a listed reviewer/);
|
||||
no(cli(repo, ["add", "--op", "add-owner-reviewer", "--piece", "x", "--gate", "g", "--brief", "docs/plans/brief-b.md#Template", "--owner", "rocko", "--gate-owner", "filbert", "--reviewer", "rocko"], { by: "sage" }), 2, /row 12 owner can't be a listed reviewer/);
|
||||
assert.deepEqual(doc(repo), before);
|
||||
no(cli(repo, ["set", "9", "reviewers", "filbert,darkwing", "--op", "set-9-owner-reviewer"], { by: "sage" }), 2, /row 9 owner darkwing can't be a listed reviewer/);
|
||||
no(cli(repo, ["set", "9", "reviewers", "filbert,darkwing", "--op", "set-9-owner-reviewer"], { by: "sage" }), 2, /row 9 owner can't be a listed reviewer/);
|
||||
assert.deepEqual(doc(repo), before);
|
||||
no(cli(repo, ["assign", "9", "filbert", "--op", "assign-9-filbert"], { by: "sage" }), 2, /can't assign row 9 to filbert: filbert is one of its reviewers/);
|
||||
no(cli(repo, ["assign", "9", "filbert", "--op", "assign-9-filbert"], { by: "sage" }), 2, /can't assign row 9: seat is one of its reviewers/);
|
||||
assert.deepEqual(doc(repo), before);
|
||||
// The unchanged accept paths: a reviewer list or a new owner that does not collide with the other.
|
||||
ok(cli(repo, ["set", "9", "reviewers", "filbert,dewey", "--op", "set-9-ok"], { by: "sage" }));
|
||||
|
||||
@@ -67,7 +67,7 @@ function logEntry(line) {
|
||||
// With last, only the newest last well-formed entries are returned.
|
||||
export function readActivationLog(dataRoot, { last } = {}) {
|
||||
if (last !== undefined && (!Number.isInteger(last) || last < 1)) {
|
||||
throw new RunsError(`last must be a positive integer (got ${JSON.stringify(last)})`);
|
||||
throw new RunsError(`last must be a positive integer`);
|
||||
}
|
||||
const state = readStateFile(dataRoot, ACTIVATION_LOG_FILE);
|
||||
if (state === null) return { entries: [], malformed: 0 };
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
function scratch(t) { const root = mkdtempSync(join(tmpdir(), 'r4-')); t.after(() => rmSync(root, { recursive: true, force: true })); return root; }
|
||||
function safe(label, Type, props = {}, omitted = secret) { return error => {
|
||||
assert.ok(error instanceof Type, error.stack);
|
||||
for (const [key, value] of Object.entries(props)) assert.equal(error[key], value);
|
||||
assert.ok(error.message.includes(label), error.message);
|
||||
assert.doesNotMatch(error.message + error.stack, /sk-test|NOT-A-SECRET|Unexpected token/);
|
||||
assert.ok(!error.message.includes(omitted), error.message);
|
||||
assert.equal(error.cause, undefined);
|
||||
return true;
|
||||
}; }
|
||||
import { readActivationLog } from '../src/state.mjs';
|
||||
import { RunsError } from '../src/errors.mjs';
|
||||
test('activation last refusal names the field without its value', () => assert.throws(() => readActivationLog('/unused', { last: secret }), safe('last', RunsError, { exitCode: 4 })));
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, lstatSync, mkdirSync, readlinkSync, realpathSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { ID_PATTERN } from "../../organization/src/vocabulary.mjs";
|
||||
|
||||
@@ -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",
|
||||
@@ -109,8 +111,12 @@ function systemBinds(node = process.execPath) {
|
||||
// sessionStore(), or null) and node. workspaceDest (row 269) is where the
|
||||
// workspace shows inside the sandbox when that isn't its host path: a legacy
|
||||
// thread session's, so its harness finds the cwd its history recorded.
|
||||
// `hidden` (#1873 A3) lists directories inside the workspace the session may not see: a project
|
||||
// checkout a launch before this one made, in a project that now excludes or refuses the instance.
|
||||
// Each gets an empty tmpfs over it, so its files stay on the host, untouched, and out of sight.
|
||||
// Each must be a real directory: a tmpfs mounted at a symlink would land on the link's target.
|
||||
// `readOnly` adds files, for tests only.
|
||||
export function binds({ runDir, workspace, workspaceDest = null, adapter, harnessMounts = [], brokerSocket, launchSocket = null, gitSocket = null, threadStore = null, node = process.execPath, readOnly = [] }) {
|
||||
export function binds({ runDir, workspace, workspaceDest = null, hidden = [], adapter, harnessMounts = [], brokerSocket, launchSocket = null, gitSocket = null, threadStore = null, node = process.execPath, readOnly = [] }) {
|
||||
const out = systemBinds(node);
|
||||
for (const f of RUNNER_FILES) out.push({ kind: "ro", src: f, why: "a repository file the runner loads" });
|
||||
out.push({ kind: "ro", src: adapter, why: "the harness adapter the runner starts each turn" });
|
||||
@@ -121,6 +127,14 @@ export function binds({ runDir, workspace, workspaceDest = null, adapter, harnes
|
||||
for (const f of readOnly) out.push({ kind: "ro", src: f, why: "a test fixture" });
|
||||
out.push(
|
||||
{ kind: "rw", src: workspace, ...(workspaceDest && workspaceDest !== workspace ? { dest: workspaceDest } : {}), why: "the instance's workspace, where the harness works" },
|
||||
...hidden.map((h) => {
|
||||
const inside = relative(workspace, h);
|
||||
if (!inside || inside.startsWith("..") || isAbsolute(inside)) throw new Error(`hidden path ${h} is not inside the workspace`);
|
||||
let real = false;
|
||||
try { real = lstatSync(h).isDirectory(); } catch { real = false; }
|
||||
if (!real) throw new Error(`hidden path ${h} is not a directory`);
|
||||
return { kind: "tmpfs", dest: join(workspaceDest ?? workspace, inside), why: "a project checkout this launch may not see, covered by an empty tmpfs" };
|
||||
}),
|
||||
{ kind: "rw", src: runDir, why: "the run directory: turns/, session/, tools.sock, turn.done, home/, tmp/" },
|
||||
{ kind: "ro", src: join(runDir, "bundle"), why: "the run's bundle stays as the host wrote it" },
|
||||
{ kind: "ro", src: join(runDir, "session.json"), why: "the session file stays as the host wrote it" },
|
||||
@@ -144,6 +158,7 @@ function argsOf(list) {
|
||||
else if (b.kind === "symlink") args.push("--symlink", b.src, b.dest);
|
||||
else if (b.kind === "dev") args.push("--dev", b.dest);
|
||||
else if (b.kind === "proc") args.push("--proc", b.dest);
|
||||
else if (b.kind === "tmpfs") args.push("--tmpfs", b.dest);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { Broker } from "../../bus/src/broker.mjs";
|
||||
@@ -91,6 +91,34 @@ test("every bind says why; the bundle and session file stay read-only over the r
|
||||
assert.ok(!list.some((b) => b.src === realpathSync(join(REPO, "node_modules"))), "no harness mounts unless the module names them");
|
||||
});
|
||||
|
||||
// #1873 A3: a checkout the launch may not see is covered by an empty tmpfs after the workspace bind,
|
||||
// at the path the session sees; a path outside the workspace is refused, never mounted.
|
||||
test("A3 a hidden checkout is a tmpfs over the workspace bind; a path outside the workspace, a symlink or a file refuses", (t) => {
|
||||
const root = scratch(t);
|
||||
const workspace = join(root, "ws");
|
||||
mkdirSync(join(workspace, "web"), { recursive: true });
|
||||
mkdirSync(join(workspace, "stack"));
|
||||
const args = { runDir: join(root, "run"), workspace, adapter: join(root, "a.sh"), brokerSocket: join(root, "bus.sock") };
|
||||
const list = binds({ ...args, hidden: [join(workspace, "web")] });
|
||||
const ws = list.findIndex((b) => b.src === workspace);
|
||||
const hide = list.findIndex((b) => b.kind === "tmpfs");
|
||||
assert.ok(ws >= 0 && hide > ws, "the tmpfs comes after the workspace bind");
|
||||
assert.equal(list[hide].dest, join(workspace, "web"));
|
||||
assert.ok(list[hide].why);
|
||||
const moved = binds({ ...args, workspaceDest: "/work", hidden: [join(workspace, "web")] });
|
||||
assert.equal(moved.find((b) => b.kind === "tmpfs").dest, "/work/web");
|
||||
assert.equal(binds(args).some((b) => b.kind === "tmpfs"), false);
|
||||
for (const bad of [workspace, join(root, "other"), join(workspace, "..", "ws2")]) {
|
||||
assert.throws(() => binds({ ...args, hidden: [bad] }), /is not inside the workspace/, bad);
|
||||
}
|
||||
// A tmpfs at a symlink would mount over its target, so only a real directory is covered.
|
||||
symlinkSync(join(workspace, "stack"), join(workspace, "linked"));
|
||||
writeFileSync(join(workspace, "file"), "x\n");
|
||||
for (const bad of [join(workspace, "linked"), join(workspace, "file"), join(workspace, "missing")]) {
|
||||
assert.throws(() => binds({ ...args, hidden: [bad] }), /is not a directory/, bad);
|
||||
}
|
||||
});
|
||||
|
||||
// Row 65: the harness module names its own mounts; binds() mounts each read-only, after the
|
||||
// adapter and before the broker socket, and names no harness itself.
|
||||
test("row 65 a harness module's mounts bind read-only after the adapter, each with the module's reason", async (t) => {
|
||||
@@ -351,7 +379,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) => {
|
||||
|
||||
@@ -154,6 +154,8 @@ const STATUS = {
|
||||
// Row 239 (#1745): a fire's or a delete's write refused after its revoke ran.
|
||||
'revision-conflict-revoked': 409, 'outcome-unknown-revoked': 503,
|
||||
'invalid-request': 400, 'unknown-role': 400, 'message-not-found': 400, 'request-too-large': 400,
|
||||
// #1873 A3: a root or project copy that can't launch.
|
||||
'credential-unbound': 409, 'copy-refused': 409, 'project-refused': 409, 'project-agent-elsewhere': 409,
|
||||
// Row 148: the spec and docs editor. A stale base, a busy or dirty repository and an unchanged
|
||||
// file are the user's to resolve by reloading; a path or content the project refuses is a 400.
|
||||
'stale-base': 409, 'project-busy': 409, 'project-dirty': 409,
|
||||
|
||||
@@ -23,6 +23,8 @@ const STATUS = {
|
||||
'project-exists': 409, 'project-archived': 409, 'thread-exists': 409, 'human-required': 403, unauthenticated: 403,
|
||||
'unknown-business': 403, 'read-only': 403, 'not-configured': 503, 'no-bus-host': 503,
|
||||
'thread-instance-missing': 503, 'thread-instance-ambiguous': 503, 'outcome-unknown': 503,
|
||||
// #1873 A3: a project agent named in a chat of another project, and a copy that can't launch.
|
||||
'project-agent-elsewhere': 409, 'credential-unbound': 409, 'copy-refused': 409, 'project-refused': 409,
|
||||
'session-not-found': 404, 'operation-not-found': 404, 'session-exists': 409,
|
||||
'session-op-conflict': 409, 'session-clear-complete': 409, 'session-name-too-long': 409, 'session-clearing': 409, 'session-revision-stale': 409,
|
||||
'session-retired': 409, 'processing-halted': 409, 'confirm-required': 400,
|
||||
@@ -37,6 +39,8 @@ function safeMessage(code) {
|
||||
if (code === 'unknown-verb' || code === 'channel-operation') return 'This session operation is unavailable until its dependency is installed.';
|
||||
if (code === 'channel-disabled') return 'Run mosaic plugins to read the revision, then mosaic plugins set --file PATCH with plugins.channel set to ["cli","webui"].';
|
||||
if (code === 'thread-not-owned') return 'This thread is not available to this user.';
|
||||
if (code === 'project-agent-elsewhere') return 'That project agent works only in its own project. Choose an agent of this project.';
|
||||
if (code === 'credential-unbound' || code === 'copy-refused' || code === 'project-refused') return 'That agent can\'t launch in this organization. The Agents grid says why.';
|
||||
if (code === 'outcome-unknown') return 'Outcome unknown. Read the current list or conversation before trying again. Nothing was retried.';
|
||||
return `Chat operation refused (${code}).`;
|
||||
}
|
||||
|
||||
@@ -60,6 +60,9 @@
|
||||
const route = () => location.hash.replace(/^#\/orgs\/[^/]+/, '') || '/';
|
||||
const ensureThemes = async () => { if (!themes) themes = await data.themes().then(b => (Array.isArray(b?.themes) ? b.themes : [])).catch(() => []); return themes; };
|
||||
const instancesOf = value => { const v = value?.rows?.[0]?.instances ?? value?.instances; return v && typeof v === 'object' && !Array.isArray(v) ? v : null; };
|
||||
// #1873 A3: the root and project agent copies the bus booted with, from the same instances read.
|
||||
// An answer without the key (a bus with no copies, or one older than A3) gives an empty map.
|
||||
const copiesOf = value => { const v = value?.rows?.[0]?.copies ?? value?.copies; return v && typeof v === 'object' && !Array.isArray(v) ? v : {}; };
|
||||
const running = item => (Array.isArray(item?.running) ? item.running.length : 0);
|
||||
const rowsOf = v => (Array.isArray(v) ? v : Array.isArray(v?.rows) ? v.rows : null);
|
||||
// An agent's activity is the state the bus derives for the run holding its role. A live process
|
||||
@@ -271,9 +274,20 @@
|
||||
|
||||
// ---------- agents levels (sample 08) ----------
|
||||
// Root and organization agents come from their files through /api/agents/levels (#1873 A1, read
|
||||
// only). Organization agents' status comes from each bus. Root and project agents are not launched
|
||||
// in A1, so they read "not running"; nothing here invents a status, a model or a memory.
|
||||
// only). Every status comes from the organization's bus: its instances, and since A3 the root and
|
||||
// project agent copies it booted with. Nothing here invents a status, a model or a memory.
|
||||
const NOT_RUNNING = ['s-idle', '–', 'not running'];
|
||||
// A copy's status: running or not as the bus reports its runs, or why it can't launch.
|
||||
const CANT = ['s-error', '▲', "can't launch"];
|
||||
const copyWhy = (c) => (c.state === 'unbound' ? `no binding in this organization for ${(c.missing || []).join(', ')}` : c.reason || 'refused');
|
||||
const copyDot = (name, c, agents) => (c.state === 'instance' ? activity(name, c, agents) : CANT);
|
||||
// The bus names each copy's instance. Find an agent's copy by its name and project, never by
|
||||
// rebuilding the instance name: a collision refuses one entry that lists every claimant.
|
||||
const copyFor = (copies, name, project = null) => {
|
||||
const mine = c => c && c.name === name && (c.project ?? null) === project;
|
||||
const hit = Object.entries(copies || {}).find(([, c]) => mine(c) || (Array.isArray(c?.claims) && c.claims.some(mine)));
|
||||
return hit ? { key: hit[0], copy: hit[1] } : { key: null, copy: null };
|
||||
};
|
||||
const LEVEL_CLS = { root: 'root', org: 'org', project: 'proj' };
|
||||
const projectName = (rows, id) => { const p = Array.isArray(rows) ? rows.find(r => r?.id === id) : null; return p?.name || id; };
|
||||
let agentsPick = null; // the project chosen in "Available in a project", kept across repaints
|
||||
@@ -307,7 +321,12 @@
|
||||
const sub = a => [a.definition && a.definition !== a.name ? a.definition : '', a.overrides?.length ? `replaces the ${a.overrides.map(o => (o.level === 'org' ? 'organization' : o.level)).join(' and ')} ${a.overrides.length > 1 ? 'definitions' : 'definition'}` : '', a.narrowed?.length ? `narrowed here: ${a.narrowed.join(', ')}` : ''].filter(Boolean).join(' · ');
|
||||
const status = a => {
|
||||
if (a.assistant) return dot('s-unknown', '?', 'status not reported', true);
|
||||
if (a.level !== 'org') return dot(...NOT_RUNNING, true);
|
||||
if (a.level !== 'org') {
|
||||
if (inst === null) return '<span class="muted">status unavailable</span>';
|
||||
const { key, copy: c } = copyFor(mine?.copies, a.name, a.level === 'project' ? a.project : null);
|
||||
if (!c) return '<span class="muted">starts with the next bus start</span>';
|
||||
return `${dot(...copyDot(key, c, mine.agents), true)}${c.state === 'instance' ? '' : `<span class="t2">${esc(copyWhy(c))}</span>`}`;
|
||||
}
|
||||
if (inst === null) return '<span class="muted">status unavailable</span>';
|
||||
return dot(...(inst[a.name] ? activity(a.name, inst[a.name], mine.agents) : NOT_RUNNING), true);
|
||||
};
|
||||
@@ -334,15 +353,15 @@
|
||||
|
||||
async function agentGrid(note = '', selected = window.MosaicOrg?.id) {
|
||||
const [list, levels] = await Promise.all([data.organizations().catch(() => null), data.agentLevels().catch(() => null), ensureThemes()]);
|
||||
const head = `<div class="ws-vh"><h1 tabindex="-1">Agents</h1><div class="ws-right"><a class="ws-btn" href="#/settings/agents" data-focus-key="agents-edit">${icon('settings')}Edit agents</a></div><p class="sub">Agents are defined at three levels: root, organization and project. Root and project agents are read from their files and are not launched yet.${note ? ` ${note}` : ''}</p></div>`;
|
||||
const head = `<div class="ws-vh"><h1 tabindex="-1">Agents</h1><div class="ws-right"><a class="ws-btn" href="#/settings/agents" data-focus-key="agents-edit">${icon('settings')}Edit agents</a></div><p class="sub">Agents are defined at three levels: root, organization and project. Each organization runs its own copy of a root agent, and a project's agents run only in that project. The copies are made when the organization's bus starts.${note ? ` ${note}` : ''}</p></div>`;
|
||||
if (!Array.isArray(list)) return `<section class="ws ws-agents" data-org-agents>${head}<p class="ws-panel tight note">Organizations unavailable.</p></section>`;
|
||||
const valid = list.filter(org => !org.invalid);
|
||||
const admitted = valid.some(org => org.id === selected);
|
||||
const [results, business] = await Promise.all([
|
||||
Promise.all(valid.map(org => Promise.all([
|
||||
data.instances(org.id).then(instancesOf).catch(() => null), data.agents(org.id).then(rowsOf).catch(() => null),
|
||||
data.instances(org.id).then(v => ({ inst: instancesOf(v), copies: copiesOf(v) })).catch(() => ({ inst: null, copies: null })), data.agents(org.id).then(rowsOf).catch(() => null),
|
||||
projects.get(org.id) !== undefined ? projects.get(org.id) : data.projects(org.id).then(b => (Array.isArray(b?.rows) ? b.rows : null)).catch(() => null),
|
||||
]).then(([inst, agents, rows]) => ({ org, inst, agents, rows, lv: levels?.orgs?.find(o => o?.id === org.id) ?? null })))),
|
||||
]).then(([{ inst, copies }, agents, rows]) => ({ org, inst, copies, agents, rows, lv: levels?.orgs?.find(o => o?.id === org.id) ?? null })))),
|
||||
admitted ? data.business(selected).catch(() => null) : null,
|
||||
]);
|
||||
const band = `<div class="ws-span-band"><span class="ws-av all md" aria-hidden="true">${LOGO}</span><b>assistant</b><span class="ws-lvl root">root</span>${dot('s-unknown', '?', 'status not reported', true)}<span>Works across</span><span class="orgs">${valid.map(org => av(org)).join('')}</span><span class="muted ws-band-note">The assistant talks with you across organizations. Its status is not reported on this page; set it up under Settings, Assistant.</span></div>`;
|
||||
@@ -350,22 +369,32 @@
|
||||
const cell = (org, inner, cls = '') => `<div class="ws-o${cls}" style="${colours(org)}" data-org="${esc(org.id)}"><span class="mlabel">${av(org, 'xs')}${esc(label(org))}</span>${inner}</div>`;
|
||||
const endc = i => (i === results.length - 1 ? ' endc' : '');
|
||||
const pill = (name, item, agents) => { const cos = item?.definition === 'chief-of-staff' || name === 'chief'; const [acls, aglyph, aword] = activity(name, item, agents); return `<span class="ws-agent${cos ? ' cos' : ''}">${dot(acls, aglyph, aword)}${esc(name)}${cos && name !== 'chief' ? ' <small>chief</small>' : ''}</span>`; };
|
||||
const idle = (name, small) => `<span class="ws-agent" data-level-agent="${esc(name)}">${dot(...NOT_RUNNING)}${esc(name)} <small>${esc(small)}</small></span>`;
|
||||
// A root or project copy in one organization's cell: its status from that organization's bus, and
|
||||
// why it can't launch when it can't. No answer from the bus reads as unavailable, never as idle.
|
||||
const copyPill = (copies, agents, name, project, small) => {
|
||||
const { key, copy: c } = copyFor(copies, name, project);
|
||||
const mark = copies === null ? ['s-unknown', '?', 'status unavailable'] : !c ? ['s-unknown', '?', 'starts with the next bus start'] : copyDot(key, c, agents);
|
||||
const why = c && c.state !== 'instance' ? `<span class="note ws-off" data-copy-why="${esc(key)}">${esc(name)}: ${esc(copyWhy(c))}</span>` : '';
|
||||
return `<span class="ws-agent" data-level-agent="${esc(name)}" data-copy-state="${esc(c?.state || 'unknown')}">${dot(...mark)}${esc(name)} <small>${esc(small)}</small></span>${why}`;
|
||||
};
|
||||
// Root: one copy per organization, each running inside that organization; an opt-out is said.
|
||||
const root = levels?.root;
|
||||
const rootRow = !root ? '<div class="span endc"><span class="muted">Root agents unavailable. The levels read did not answer.</span></div>'
|
||||
: root.state === 'invalid' ? `<div class="span endc"><span class="muted">Root agents unavailable. ${esc(root.reason)}</span></div>`
|
||||
: !root.agents.some(a => a.definition !== 'assistant') ? '<div class="span endc"><span class="muted">No root agents. They are defined in agents.json in your configuration directory.</span></div>'
|
||||
: results.map(({ org }, i) => {
|
||||
: results.map(({ org, inst, copies, agents }, i) => {
|
||||
const mineRoot = root.agents.filter(a => a.definition !== 'assistant');
|
||||
const on = mineRoot.filter(a => !a.excludeOrgs.includes(org.id)), off = mineRoot.filter(a => a.excludeOrgs.includes(org.id));
|
||||
return cell(org, `${on.map(a => idle(a.name, 'copy')).join('') || '<span class="muted">none</span>'}${off.length ? `<span class="note ws-off">${esc(off.map(a => a.name).join(', '))} turned off for ${esc(label(org))}</span>` : ''}`, endc(i));
|
||||
// An organization agent of the same name replaces the root one there, so no copy is made.
|
||||
const own = a => Boolean(inst && Object.hasOwn(inst, a.name) && !copyFor(copies, a.name).copy);
|
||||
const kept = mineRoot.filter(a => !a.excludeOrgs.includes(org.id)), off = mineRoot.filter(a => a.excludeOrgs.includes(org.id));
|
||||
const on = kept.filter(a => !own(a)), replaced = kept.filter(own);
|
||||
return cell(org, `${on.map(a => copyPill(copies, agents, a.name, null, 'copy')).join('') || '<span class="muted">none</span>'}${replaced.length ? `<span class="note ws-off">${esc(replaced.map(a => a.name).join(', '))} replaced by ${esc(label(org))}'s own</span>` : ''}${off.length ? `<span class="note ws-off">${esc(off.map(a => a.name).join(', '))} turned off for ${esc(label(org))}</span>` : ''}`, endc(i));
|
||||
}).join('');
|
||||
const orgRow = results.map(({ org, inst, agents }, i) => cell(org, inst === null ? '<span class="muted">Agents unavailable</span>' : Object.keys(inst).length ? Object.entries(inst).map(([k, v]) => pill(k, v, agents)).join('') : '<span class="muted">No agents configured</span>', endc(i))).join('');
|
||||
// Project: the agents a project defines itself. Narrowing and exclusion show in the table.
|
||||
const projRow = results.map(({ org, lv, rows }, i) => {
|
||||
const projRow = results.map(({ org, lv, rows, copies, agents }, i) => {
|
||||
if (lv?.state !== 'ok') return cell(org, `<span class="muted">${lv?.state === 'invalid' ? 'Project agents unavailable: the organization file was refused' : 'Project agents unavailable'}</span>`, ` last${endc(i)}`);
|
||||
const own = Object.entries(lv.defined).flatMap(([pid, entries]) => entries.filter(e => e.form === 'define').map(e => idle(e.name, projectName(rows, pid))));
|
||||
const own = Object.entries(lv.defined).flatMap(([pid, entries]) => entries.filter(e => e.form === 'define').map(e => copyPill(copies, agents, e.name, pid, projectName(rows, pid))));
|
||||
return cell(org, own.join('') || '<span class="muted">none</span>', ` last${endc(i)}`);
|
||||
}).join('');
|
||||
const levelsGrid = valid.length ? `<div class="ws-levels" style="grid-template-columns:200px repeat(${n},minmax(0,1fr))">
|
||||
|
||||
@@ -446,6 +446,12 @@ const AGENT_REASONS = {
|
||||
'unknown-role': 'the business has no such instance',
|
||||
'unknown-request': 'the bus has no such request',
|
||||
'instance-not-listed': 'the instance isn\'t on the launch list',
|
||||
// #1873 A3: a root or project copy that can't launch. The Agents grid names the agent and the
|
||||
// missing service or the reason; these lines carry no value.
|
||||
'credential-unbound': 'the agent needs a credential service this organization has no binding for; the Agents grid names it',
|
||||
'copy-refused': 'this organization refused its copy of the root agent; the Agents grid says why',
|
||||
'project-refused': 'the project agent was refused, or its project refuses its agents; the Agents grid says why',
|
||||
'project-agent-elsewhere': 'a project agent works only in its own project',
|
||||
};
|
||||
const reasonFor = (code, done) => {
|
||||
if (code === 'skill-not-enabled' && /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(done?.skill ?? ''))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,7 +12,7 @@ const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<150;i++)
|
||||
|
||||
// Three organizations as sample 01 and 03 show them: two running buses and one stopped. P2: each running
|
||||
// organization's System types read comes from the installed modules; `send` adds the owner's writes.
|
||||
function workspaceOrgs({ failThreads = false, seen = null, send = false, types = {} } = {}) {
|
||||
function workspaceOrgs({ failThreads = false, seen = null, send = false, types = {}, copies = {}, buses = {} } = {}) {
|
||||
const ORGS = [
|
||||
{ id: 'ddigital', label: 'Distinctly Digital', theme: 'rosewood', busHealthy: true },
|
||||
{ id: 'forge', label: 'Forge', theme: 'cobalt', busHealthy: true },
|
||||
@@ -30,7 +30,9 @@ function workspaceOrgs({ failThreads = false, seen = null, send = false, types =
|
||||
const fakes = {};
|
||||
const FILES = { 'spring-launch': ['docs/north-star.md', 'docs/launch-plan.md', 'src/index.js'], 'brand-refresh': ['docs/brand-voice-guide.md'], 'api-gateway': [] };
|
||||
const instances = id => ({ business: id, revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {},
|
||||
instances: Object.fromEntries(Object.entries(INSTANCES[id]).map(([n, [definition, running]]) => [n, { definition, vars: {}, launched: true, launcher: false, protected: false, kept: false, running, alive: [] }])) });
|
||||
instances: Object.fromEntries(Object.entries(INSTANCES[id]).map(([n, [definition, running]]) => [n, { definition, vars: {}, launched: true, launcher: false, protected: false, kept: false, running, alive: [] }])),
|
||||
// #1873 A3: the bus's copies key, present only when the test gives the organization copies.
|
||||
...(copies[id] ? { copies: copies[id] } : {}) });
|
||||
const business = id => ({ business: { id, human: 'jason', arbiters: ['chief'], projects: PROJECTS[id].map(p => p.id), vars: id === 'ddigital' ? { 'tracker.kind': 'vikunja' } : {},
|
||||
instances: Object.keys(INSTANCES[id]).map(n => ({ instance: n, vars: { model: 'sonnet-5.5' } })), appearance: { theme: ORGS.find(o => o.id === id).theme } } });
|
||||
return {
|
||||
@@ -45,7 +47,8 @@ function workspaceOrgs({ failThreads = false, seen = null, send = false, types =
|
||||
busHealthy: true, requestRead: st.requestRead, systemTypesWrite: send ? st.systemTypesWrite : null, ...(send ? { send: async () => ({}) } : {}),
|
||||
reads: { business: async () => business(id), settings: async () => ({ release: '0.0.42', notifier: { binding: 'discord' }, launch: { limits: { total: 4 } } }),
|
||||
queue: async () => ({ rows: id === 'ddigital' ? [{ state: 'done' }, { state: 'in-progress' }, { state: 'queued' }] : [] }), busStatus: async () => ({}) },
|
||||
bus: { instances: async () => instances(id), messages: async () => ({ messages: [], unread: 0 }), agents: async () => AGENTS[id], 'thread.claims': async () => CLAIMS[id], inbox: async () => [], tasks: async () => [] },
|
||||
// `buses` hands an organization's instance read to a real booted bus instead.
|
||||
bus: { instances: async () => (buses[id] ? buses[id]() : instances(id)), messages: async () => ({ messages: [], unread: 0 }), agents: async () => AGENTS[id], 'thread.claims': async () => CLAIMS[id], inbox: async () => [], tasks: async () => [] },
|
||||
chat: { projects: async () => PROJECTS[id], threads: async () => { if (failThreads) throw new Error('threads down'); return THREADS[id]; } },
|
||||
projectFiles: async ({ project }) => ({ head: 'a'.repeat(40), files: (FILES[project] || []).map(path => ({ path })) }),
|
||||
};
|
||||
@@ -426,13 +429,14 @@ test('agents grid shows each agent\'s level and where it is defined, per project
|
||||
['social-scheduler', 'PROJECT', 'Spring launch project', 'This organization only'],
|
||||
]);
|
||||
const byName = Object.fromEntries(rows.map(r => [r[0].split(' ')[0], r]));
|
||||
assert.equal(byName.researcher[4], '– not running'); assert.equal(byName.researcher[5], 'opus-5.5');
|
||||
// A3: this fixture bus booted with no copies, so a copy's status is the next boot.
|
||||
assert.equal(byName.researcher[4], 'starts with the next bus start'); assert.equal(byName.researcher[5], 'opus-5.5');
|
||||
assert.equal(byName.reviewer[5], 'not set'); assert.equal(byName.assistant[4], '? status not reported');
|
||||
// The model the root file sets for the assistant, shown as configured.
|
||||
assert.equal(byName.assistant[5], 'opus-5.5');
|
||||
assert.equal(byName.chief[4], '◐ waiting on you'); assert.equal(byName.chief[5], 'sonnet-5.5 Change');
|
||||
assert.match(byName.editor[0], /narrowed here: limits\.network/);
|
||||
assert.equal(byName['social-scheduler'][4], '– not running'); assert.equal(byName['social-scheduler'][5], 'haiku-5.5');
|
||||
assert.equal(byName['social-scheduler'][4], 'starts with the next bus start'); assert.equal(byName['social-scheduler'][5], 'haiku-5.5');
|
||||
assert.match(byName['social-scheduler'][0], /coder/);
|
||||
assert.match(await b.evaluate('document.querySelector("[data-agents-available] .ws-away").innerText'), /Not available here: every Forge or Home agent\./);
|
||||
// Another project: its exclusion and the first project's own agent are said, not hidden.
|
||||
@@ -446,3 +450,131 @@ test('agents grid shows each agent\'s level and where it is defined, per project
|
||||
await waitFor(b)('document.querySelector("[data-org-agents] h2")?.innerText === "Agents in Home"');
|
||||
assert.match(await b.evaluate('document.querySelector("[data-agents-available]").innerText'), /reviewer \(turned off for Home\)/);
|
||||
});
|
||||
|
||||
// #1873 A3: the same grid, fed by a booted bus. Distinctly Digital's bus boots from a real organization
|
||||
// file and root file: gate is a bound copy with a real claim, researcher is unbound (its role needs
|
||||
// gitea), stack's checker is a bound project copy and web's union refuses (it excludes an agent
|
||||
// nothing defines), so web's lint says why. The test secret sits in the organization's coder token
|
||||
// and never reaches the page.
|
||||
test('agents grid takes a running copy, an unbound copy and a refused project from a booted bus', { timeout: 90000 }, async t => {
|
||||
const { join } = await import('node:path');
|
||||
const { mkdirSync, writeFileSync } = await import('node:fs');
|
||||
const { checkRootAgents } = await import('../../organization/src/index.mjs');
|
||||
const { launchDoc, tokenFile, writeJson } = await import('../../organization/tests/helpers.mjs');
|
||||
const { fixture, tmp, withoutHostCredentials } = await import('../../cli/tests/helpers.mjs');
|
||||
const { bootConfig, loadSystem } = await import('../../cli/src/config.mjs');
|
||||
const { startTimeOf } = await import('../../cli/src/host.mjs');
|
||||
const { startBroker } = await import('../../bus/src/runtime.mjs');
|
||||
const { Client } = await import('../../bus/src/client.mjs');
|
||||
const SECRET = 'sk-test-NOT-A-SECRET-1234';
|
||||
const VARS = { harness: 'pi', model: 'claude-sonnet-5-5', thinking: 'high' };
|
||||
const rolesDir = fileURLToPath(new URL('../../../roles', import.meta.url));
|
||||
const dir = tmp(t, 'ws-copy-');
|
||||
const f = fixture(dir, 'ddigital', doc => {
|
||||
doc.roles.chief.vars = { harness: 'pi', model: 'claude-opus-5-5', thinking: 'high' };
|
||||
doc.roles.coder.vars.model = 'claude-sonnet-5-5';
|
||||
const secret = tokenFile(join(dir, 'secrets'), SECRET);
|
||||
writeFileSync(secret, SECRET);
|
||||
doc.roles.coder.credentials.gitea = { ...doc.roles.coder.credentials.gitea, file: secret };
|
||||
doc.launch = launchDoc('chief', ['coder'], { opus: 1, sonnet: 4 });
|
||||
doc.projects.stack.agents = { checker: { definition: 'merge-gate', vars: VARS } };
|
||||
doc.projects.web = { root: join(dir, 'web'), agents: { lint: { definition: 'merge-gate', vars: VARS }, ghost: { exclude: true } } };
|
||||
return doc;
|
||||
});
|
||||
mkdirSync(join(dir, 'web'), { recursive: true });
|
||||
withoutHostCredentials(f.env, { piKey: true });
|
||||
const rootDoc = { agentsVersion: 1, agents: { gate: { definition: 'merge-gate', vars: VARS }, researcher: { definition: 'research', vars: VARS } } };
|
||||
writeJson(join(dir, 'config', 'agents.json'), rootDoc, 0o600);
|
||||
const boot = (({ trackers, ...rest }) => rest)(bootConfig({ system: loadSystem({ env: f.env }), businessId: 'ddigital', env: f.env }));
|
||||
const runtime = await startBroker(boot); t.after(() => runtime.close());
|
||||
// gate runs: a launch bound to the bus and its claim, as the launcher makes them.
|
||||
const bound = await runtime.bindLaunch({ business: 'ddigital', role: 'gate', run: 'gate-run', harness: 'pi', pid: process.pid, startTime: startTimeOf(process.pid) });
|
||||
await new Client({ path: runtime.path, cap: bound.cap }).call('role.claim');
|
||||
const human = runtime.broker.bindHuman({ business: 'ddigital', human: f.doc.human, via: 'cli', outsideAgent: true });
|
||||
const read = () => runtime.broker.request(human, { verb: 'instances' });
|
||||
assert.deepEqual(Object.fromEntries(Object.entries(read().copies).map(([k, c]) => [k, c.state])), { gate: 'instance', researcher: 'unbound', 'checker.stack': 'instance', 'lint.web': 'refused' });
|
||||
const orgs = workspaceOrgs({ buses: { ddigital: async () => read() } });
|
||||
const DOCS = { ddigital: f.doc, forge: { roles: { chief: { definition: 'chief-of-staff' } }, projects: {} }, home: { roles: {}, projects: {} } };
|
||||
const organizations = { ...orgs, members: async () => Object.entries(DOCS).map(([id, doc]) => ({ id, doc })) };
|
||||
const root = { state: 'ok', ...checkRootAgents(rootDoc, { rolesDir }) };
|
||||
const agentLevels = { loadRoot: () => root, rolesDir, ctx: { configDir: '/cfg', dataRoot: '/data', instance: 'test' } };
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations, agentLevels, log: () => {} }); 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/ddigital/agents`);
|
||||
await waitFor(b)('document.querySelectorAll(".ws-levels [data-org=ddigital] [data-copy-state]").length === 4');
|
||||
const pills = await b.evaluate('[...document.querySelectorAll(".ws-levels [data-org=ddigital] [data-copy-state]")].map(p => [p.dataset.levelAgent, p.dataset.copyState, p.querySelector(".ws-dot").title])');
|
||||
assert.deepEqual(pills, [['gate', 'instance', 'running'], ['researcher', 'unbound', "can't launch"], ['checker', 'instance', 'not running'], ['lint', 'refused', "can't launch"]]);
|
||||
const why = await b.evaluate('Object.fromEntries([...document.querySelectorAll(".ws-levels [data-org=ddigital] [data-copy-why]")].map(n => [n.dataset.copyWhy, n.innerText.trim()]))');
|
||||
assert.deepEqual(Object.keys(why), ['researcher', 'lint.web']);
|
||||
assert.equal(why.researcher, 'researcher: no binding in this organization for gitea');
|
||||
assert.match(why['lint.web'], /^lint: project web refuses its agents: .*ghost/);
|
||||
const text = await b.evaluate('document.body.innerText');
|
||||
for (const value of [SECRET, dir]) assert.equal(text.includes(value), false, value);
|
||||
});
|
||||
|
||||
// #1873 A3: the grid's copy status comes from each bus's copies. Distinctly Digital runs its researcher
|
||||
// copy, its reviewer copy is unbound (the reason names the services, never a value), and Forge's
|
||||
// api-gateway project refuses, so its load-tester says why, naming the project.
|
||||
test('agents grid shows a running copy, an unbound copy and a refused project with their reasons', async t => {
|
||||
const { checkRootAgents } = await import('../../organization/src/index.mjs');
|
||||
const rolesDir = fileURLToPath(new URL('../../../roles', import.meta.url));
|
||||
const root = { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: {
|
||||
assistant: { definition: 'assistant' },
|
||||
researcher: { definition: 'research', vars: { model: 'opus-5.5' } },
|
||||
reviewer: { definition: 'reviewer', excludeOrgs: ['home'] },
|
||||
} }, { rolesDir }) };
|
||||
const DOCS = {
|
||||
ddigital: { roles: { chief: { definition: 'chief-of-staff' } }, projects: { 'spring-launch': { root: '/p/spring', agents: { 'social-scheduler': { definition: 'research' } } } } },
|
||||
forge: { roles: { chief: { definition: 'chief-of-staff' } }, projects: { 'api-gateway': { root: '/p/api', agents: { 'load-tester': { definition: 'research' } } } } },
|
||||
home: { roles: {}, projects: {} },
|
||||
};
|
||||
const copy = (name, project, state, extra = {}) => ({ name, level: project ? 'project' : 'root', project, definition: 'research', state, running: [], ...extra });
|
||||
const copies = {
|
||||
ddigital: { researcher: copy('researcher', null, 'instance', { running: ['r9'] }), reviewer: copy('reviewer', null, 'unbound', { definition: 'reviewer', missing: ['gitea', 'vikunja'] }),
|
||||
'social-scheduler.spring-launch': copy('social-scheduler', 'spring-launch', 'instance') },
|
||||
forge: { researcher: copy('researcher', null, 'instance'), reviewer: copy('reviewer', null, 'unbound', { definition: 'reviewer', missing: ['gitea', 'vikunja'] }),
|
||||
'load-tester.api-gateway': copy('load-tester', 'api-gateway', 'refused', { reason: 'project api-gateway refuses its agents: instance load-tester: model is not one the organization lists' }) },
|
||||
};
|
||||
const orgs = workspaceOrgs({ copies });
|
||||
const organizations = { ...orgs, members: async () => Object.entries(DOCS).map(([id, doc]) => ({ id, doc })) };
|
||||
const agentLevels = { loadRoot: () => root, rolesDir, ctx: { configDir: '/cfg', dataRoot: '/data', instance: 'test' } };
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations, agentLevels, log: () => {} }); 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/ddigital/agents`);
|
||||
await waitFor(b)('document.querySelectorAll(".ws-levels [data-copy-state]").length === 7');
|
||||
const pills = await b.evaluate('[...document.querySelectorAll(".ws-levels [data-org]")].flatMap(c => [...c.querySelectorAll("[data-copy-state]")].map(p => [c.dataset.org, p.dataset.levelAgent, p.dataset.copyState, p.querySelector(".ws-dot").title]))');
|
||||
assert.deepEqual(pills, [
|
||||
['ddigital', 'researcher', 'instance', 'running'], ['ddigital', 'reviewer', 'unbound', "can't launch"],
|
||||
['forge', 'researcher', 'instance', 'not running'], ['forge', 'reviewer', 'unbound', "can't launch"],
|
||||
// Home's bus is down: no answer reads as unavailable, never as idle.
|
||||
['home', 'researcher', 'unknown', 'status unavailable'],
|
||||
['ddigital', 'social-scheduler', 'instance', 'not running'], ['forge', 'load-tester', 'refused', "can't launch"],
|
||||
]);
|
||||
const why = await b.evaluate('Object.fromEntries([...document.querySelectorAll(".ws-levels [data-org]")].flatMap(c => [...c.querySelectorAll("[data-copy-why]")].map(n => [`${c.dataset.org}:${n.dataset.copyWhy}`, n.innerText.trim()])))');
|
||||
assert.deepEqual(why, {
|
||||
'ddigital:reviewer': 'reviewer: no binding in this organization for gitea, vikunja',
|
||||
'forge:reviewer': 'reviewer: no binding in this organization for gitea, vikunja',
|
||||
'forge:load-tester.api-gateway': 'load-tester: project api-gateway refuses its agents: instance load-tester: model is not one the organization lists',
|
||||
});
|
||||
// The table for Distinctly Digital's first project: the copies' status from its bus, the reason under an unbound one.
|
||||
const rows = await b.evaluate('Object.fromEntries([...document.querySelectorAll("[data-agents-available] tbody tr")].map(tr => [tr.cells[0].innerText.trim().split(/\\s/)[0], tr.cells[4].innerText.replace(/\\s+/g, " ").trim()]))');
|
||||
assert.equal(rows.researcher, '● running');
|
||||
assert.equal(rows.reviewer, "▲ can't launch no binding in this organization for gitea, vikunja");
|
||||
assert.equal(rows['social-scheduler'], '– not running');
|
||||
assert.doesNotMatch(await b.evaluate('document.body.innerText'), /not launched yet/);
|
||||
const shots = process.env.MOSAIC_UX_SHOTS;
|
||||
if (shots) {
|
||||
const { mkdir } = await import('node:fs/promises');
|
||||
const { join } = await import('node:path');
|
||||
await mkdir(shots, { recursive: true });
|
||||
for (const [width, height] of [[1440, 900], [390, 844]]) for (const mode of ['light', 'dark']) {
|
||||
await b.viewport(width, height);
|
||||
// The header's switch sets this browser's mode for the organization, so the theme's colours follow.
|
||||
await b.evaluate(`(() => { const s = document.querySelector("#theme-switch"); if ((s.getAttribute("aria-checked") === "true") !== ${mode === 'dark'}) s.click(); scrollTo(0, 0); document.querySelector("main, .ws-main")?.scrollTo?.(0, 0); })()`);
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
await b.screenshot(join(shots, `a3-agents-${width}-${mode}.png`));
|
||||
}
|
||||
}
|
||||
});
|
||||
+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.
|
||||
|
||||
+11
-11
@@ -195,11 +195,11 @@ function validateDataRoot(value, file) {
|
||||
fail(2, 'execution "dataRoot" contains a NUL byte');
|
||||
}
|
||||
if (!path.isAbsolute(value)) {
|
||||
fail(2, `execution "dataRoot" must be an absolute path (got "${value}")`);
|
||||
fail(2, `execution "dataRoot" must be an absolute path`);
|
||||
}
|
||||
const resolved = path.resolve(value);
|
||||
if (resolved !== value) {
|
||||
fail(2, `execution "dataRoot" must be canonical without ".", "..", trailing slashes, or redundant separators (got "${value}")`);
|
||||
fail(2, `execution "dataRoot" must be canonical without ".", "..", trailing slashes, or redundant separators`);
|
||||
}
|
||||
if (resolved === path.parse(resolved).root) {
|
||||
fail(2, 'execution "dataRoot" must not be the filesystem root');
|
||||
@@ -252,7 +252,7 @@ function validateFederationOrigin(value, where) {
|
||||
url = null;
|
||||
}
|
||||
if (url === null || url.protocol !== "https:" || url.origin !== value) {
|
||||
fail(2, `${where} must be an https origin with no path, query, or userinfo (got ${JSON.stringify(value)})`);
|
||||
fail(2, `${where} must be an https origin with no path, query, or userinfo`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,7 +262,7 @@ function validateFederation(value) {
|
||||
}
|
||||
rejectUnknownKeys(value, ["self", "peers"], "federation");
|
||||
if (typeof value.self !== "string" || !FEDERATION_ID_PATTERN.test(value.self)) {
|
||||
fail(2, `federation.self must match ${FEDERATION_ID_PATTERN} (got ${JSON.stringify(value.self)})`);
|
||||
fail(2, `federation.self must match ${FEDERATION_ID_PATTERN}`);
|
||||
}
|
||||
if (!Array.isArray(value.peers)) {
|
||||
fail(2, 'federation.peers must be an array');
|
||||
@@ -275,10 +275,10 @@ function validateFederation(value) {
|
||||
}
|
||||
rejectUnknownKeys(peer, ["id", "origin", "label"], where);
|
||||
if (typeof peer.id !== "string" || !FEDERATION_ID_PATTERN.test(peer.id)) {
|
||||
fail(2, `${where}.id must match ${FEDERATION_ID_PATTERN} (got ${JSON.stringify(peer.id)})`);
|
||||
fail(2, `${where}.id must match ${FEDERATION_ID_PATTERN}`);
|
||||
}
|
||||
if (ids.has(peer.id)) {
|
||||
fail(2, `${where}.id ${JSON.stringify(peer.id)} is already named (by federation.self or an earlier peer)`);
|
||||
fail(2, `${where}.id is already named (by federation.self or an earlier peer)`);
|
||||
}
|
||||
ids.add(peer.id);
|
||||
validateFederationOrigin(peer.origin, `${where}.origin`);
|
||||
@@ -306,10 +306,10 @@ async function validate(document, file) {
|
||||
);
|
||||
|
||||
if (document.configVersion !== SUPPORTED_CONFIG_VERSION) {
|
||||
fail(2, `unsupported configVersion: ${JSON.stringify(document.configVersion)} (supported: ${SUPPORTED_CONFIG_VERSION})`);
|
||||
fail(2, `unsupported configVersion: (supported: ${SUPPORTED_CONFIG_VERSION})`);
|
||||
}
|
||||
if (!SUPPORTED_ENVIRONMENTS.has(document.environment)) {
|
||||
fail(2, `unsupported environment: ${JSON.stringify(document.environment)} (supported: ${[...SUPPORTED_ENVIRONMENTS].join(", ")})`);
|
||||
fail(2, `unsupported environment: (supported: ${[...SUPPORTED_ENVIRONMENTS].join(", ")})`);
|
||||
}
|
||||
|
||||
const dataRoot = validateDataRoot(document.dataRoot, file);
|
||||
@@ -319,12 +319,12 @@ async function validate(document, file) {
|
||||
}
|
||||
rejectUnknownKeys(document.execution, ["backend", "provider", "model", "adapter"], '"execution"');
|
||||
if (!SUPPORTED_BACKENDS.has(document.execution.backend)) {
|
||||
fail(2, `unsupported execution.backend: ${JSON.stringify(document.execution.backend)} (supported: ${[...SUPPORTED_BACKENDS].join(", ")})`);
|
||||
fail(2, `unsupported execution.backend: (supported: ${[...SUPPORTED_BACKENDS].join(", ")})`);
|
||||
}
|
||||
for (const key of ["provider", "model"]) {
|
||||
const value = document.execution[key];
|
||||
if (typeof value !== "string" || !NAME_PATTERN.test(value)) {
|
||||
fail(2, `execution.${key} must match ${NAME_PATTERN} (got ${JSON.stringify(value)})`);
|
||||
fail(2, `execution.${key} must match ${NAME_PATTERN}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,7 +332,7 @@ async function validate(document, file) {
|
||||
? "pi"
|
||||
: document.execution.adapter;
|
||||
if (typeof adapter !== "string" || !SUPPORTED_ADAPTERS.has(adapter)) {
|
||||
fail(2, `unsupported execution.adapter: ${JSON.stringify(adapter)} (supported: ${[...SUPPORTED_ADAPTERS].join(", ")})`);
|
||||
fail(2, `unsupported execution.adapter: (supported: ${[...SUPPORTED_ADAPTERS].join(", ")})`);
|
||||
}
|
||||
|
||||
// Last, so the older checks keep their order. The key is in the output only when the file has
|
||||
|
||||
@@ -84,7 +84,7 @@ function readJsonFile(file, what) {
|
||||
|
||||
function validateId(value, what) {
|
||||
if (typeof value !== "string" || !ID_PATTERN.test(value)) {
|
||||
fail(2, `${what} must match ${ID_PATTERN} (got ${JSON.stringify(value)})`);
|
||||
fail(2, `${what} must match ${ID_PATTERN}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -93,7 +93,7 @@ function validateMission(document, file) {
|
||||
if (!isPlainObject(document)) fail(2, "mission must be a JSON object");
|
||||
rejectUnknownKeys(document, ["missionVersion", "id", "objective", "directives", "capabilities"], "mission");
|
||||
if (document.missionVersion !== 1) {
|
||||
fail(2, `unsupported missionVersion: ${JSON.stringify(document.missionVersion)} (supported: 1)`);
|
||||
fail(2, `unsupported missionVersion: (supported: 1)`);
|
||||
}
|
||||
validateId(document.id, "mission id");
|
||||
if (typeof document.objective !== "string" || document.objective.trim().length === 0 || document.objective.length > 4000) {
|
||||
@@ -123,9 +123,9 @@ function validateMission(document, file) {
|
||||
const seen = new Set();
|
||||
for (const tool of document.capabilities.tools) {
|
||||
if (!SUPPORTED_TOOLS.includes(tool)) {
|
||||
fail(2, `unsupported mission tool: ${JSON.stringify(tool)} (supported: ${SUPPORTED_TOOLS.join(", ")})`);
|
||||
fail(2, `unsupported mission tool: (supported: ${SUPPORTED_TOOLS.join(", ")})`);
|
||||
}
|
||||
if (seen.has(tool)) fail(2, `duplicate tool in mission capabilities.tools: ${tool}`);
|
||||
if (seen.has(tool)) fail(2, `duplicate tool in mission capabilities.tools`);
|
||||
seen.add(tool);
|
||||
}
|
||||
capabilities = { tools: [...seen] };
|
||||
@@ -138,7 +138,7 @@ function validateTask(document, file) {
|
||||
if (!isPlainObject(document)) fail(2, "task must be a JSON object");
|
||||
rejectUnknownKeys(document, ["taskVersion", "id", "prompt", "mission", "expectExact", "timeoutSeconds", "workspace", "capabilities", "session", "sessionForkFrom"], "task");
|
||||
if (document.taskVersion !== 1) {
|
||||
fail(2, `unsupported taskVersion: ${JSON.stringify(document.taskVersion)} (supported: 1)`);
|
||||
fail(2, `unsupported taskVersion: (supported: 1)`);
|
||||
}
|
||||
validateId(document.id, "task id");
|
||||
|
||||
@@ -228,9 +228,9 @@ function validateTask(document, file) {
|
||||
const seen = new Set();
|
||||
for (const tool of document.capabilities.tools) {
|
||||
if (!SUPPORTED_TOOLS.includes(tool)) {
|
||||
fail(2, `unsupported tool: ${JSON.stringify(tool)} (supported: ${SUPPORTED_TOOLS.join(", ")})`);
|
||||
fail(2, `unsupported tool: (supported: ${SUPPORTED_TOOLS.join(", ")})`);
|
||||
}
|
||||
if (seen.has(tool)) fail(2, `duplicate tool in capabilities.tools: ${tool}`);
|
||||
if (seen.has(tool)) fail(2, `duplicate tool in capabilities.tools`);
|
||||
seen.add(tool);
|
||||
}
|
||||
tools = [...seen];
|
||||
|
||||
@@ -238,7 +238,7 @@ expect_fed_refusal "federation peer id pattern exits 2 naming the key" "federati
|
||||
FED_LONG_LABEL="$(printf 'x%.0s' $(seq 81))"
|
||||
expect_fed_refusal "federation peer label over 80 characters exits 2 naming the key" "federation.peers[0].label" \
|
||||
'{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com","label":"'"$FED_LONG_LABEL"'"}]}'
|
||||
expect_fed_refusal "federation duplicate peer id exits 2 naming the key" 'federation.peers[1].id "factory" is already named' \
|
||||
expect_fed_refusal "federation duplicate peer id exits 2 naming the key" 'federation.peers[1].id is already named' \
|
||||
'{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com","label":"F"},{"id":"factory","origin":"https://other.woltje.com","label":"G"}]}'
|
||||
expect_fed_refusal "federation peer origin with a trailing slash exits 2 naming the key" "federation.peers[0].origin" \
|
||||
'{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com/","label":"F"}]}'
|
||||
|
||||
@@ -317,7 +317,7 @@ EOF
|
||||
&& printf '%s\n' "$V2_OUT" | grep -q '^MOSAIC_ROLE_NETWORK=api-only$' || V2_OK=1
|
||||
done
|
||||
check "shipped version 2 roles resolve with their contracts (pm, cto, coder, reviewer)" "$V2_OK"
|
||||
printf '# v2\n' > "$SANDBOX/roles/v2.md"
|
||||
printf '# v2\n\n## Authority\n' > "$SANDBOX/roles/v2.md"
|
||||
v2role() { # v2role NAME AUTHORITY_JSON CREDENTIALS_JSON [CONTRACT]
|
||||
printf '{"roleVersion":2,"name":"%s","title":"T","contract":"%s","tools":["read"],"network":"none","authority":%s,"credentials":%s}' \
|
||||
"$1" "${4:-v2.md}" "$2" "$3" > "$SANDBOX/roles/$1.json"
|
||||
|
||||
Reference in new issue
Block a user