Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e25ef0373c |
No files matched your search
@@ -1,98 +0,0 @@
|
||||
# Row 227, Case 2 C2-14: end-to-end acceptance on a scratch root (#1728)
|
||||
|
||||
Owner Gizmoduck (sage-3581, 10-23), reviewer Dewey, who also runs the script.
|
||||
Brief: `docs/plans/2026-10-20_case2-rows.md`, section "C2-14 end-to-end acceptance on a scratch
|
||||
root", read at 45dca69f6. Branch `gizmoduck/row227` on 45dca69f6.
|
||||
|
||||
## Files
|
||||
|
||||
- `scripts/test-case2.sh` (new). The entry point: syntax check, one driver run, one counted line per
|
||||
step, an order check on the step ids, and a check that the made-up token is in no driver output.
|
||||
- `scripts/case2/e2e.mjs` (new), its fixture. Every step runs in one node process, because her remote
|
||||
is row 199's fake Gitea over TLS with a scratch CA, and it lives only as long as the process that
|
||||
started it. `checkMapping` takes `https://` remotes only (`packages/library/src/mapping.mjs:46-48`),
|
||||
so a plain file remote isn't an option.
|
||||
|
||||
The driver reuses the test fixtures already on refactor and adds none of its own:
|
||||
`packages/seat/tests/fake-gitea.mjs` (`scratchCa`, `fakeGitea`) and `packages/library/tests/helpers.mjs`
|
||||
(`BASE`, her layout's tree, `MARKER`, `git`, `mappingEntry`).
|
||||
|
||||
## Two stand-ins
|
||||
|
||||
1. **The human.** `human-cli.mjs` proves a human shell and refuses any caller with an agent among its
|
||||
ancestors (`packages/bus/src/human.mjs`; `packages/cli/tests/access.test.mjs:3-4,285-286` pins the
|
||||
refusal). Dewey and I both run under an agent, so a shell driver of human-cli would refuse every
|
||||
human verb. The driver binds the human in-process with `broker.bindHuman`, as the bus tests do
|
||||
(`packages/bus/tests/library-sync.test.mjs:27`). The bus checks after the bind are the real ones.
|
||||
2. **The fake harness.** No launcher and no model. The driver binds the creative instance's run on her
|
||||
thread (`bindLaunch` with `thread`), claims it, receives her message and answers in reply to it.
|
||||
That covers the thread routing and the companion's messaging lists (`roles/companion.json`), and
|
||||
leaves the launcher out. If Dewey wants the launcher in the path, `fakeHarnesses`
|
||||
(`packages/cli/tests/helpers.mjs:133`) is the way in. That is a bigger fixture, and I'd rather take
|
||||
it with C2-06's own fake harness (brief C2-06) than build a second one here.
|
||||
|
||||
## Steps
|
||||
|
||||
The ids are the driver's. "Waits on" names every row whose code the step needs and that isn't on
|
||||
refactor at 45dca69f6. A waiting step prints `skip <id> waits on row <ids>: <text>`, counted apart from
|
||||
pass and fail. When its rows land, the step gets written and its skip line goes.
|
||||
|
||||
| Step | Brief text | State |
|
||||
| --- | --- | --- |
|
||||
| S1a | bare repo in her layout | runs: classify on the fixture mapping (canon, manuscript, playground, unmapped, second universe) |
|
||||
| S1b | with a supporting folder | waits on 283 (C2-01d, supporting folders and the editor field) |
|
||||
| S2a | it syncs | runs: `library.sync` over https from the fake Gitea, served is her head |
|
||||
| S2b | | runs: `library.file` reads canon and playground at served, refuses an unmapped note `project-read-path` |
|
||||
| S2c | | runs: her fast-forward push is served by the next sync |
|
||||
| S2d | | runs: the token is under no file of the data root, the mirror included |
|
||||
| S3a | asks a creative agent in its thread | runs: project, thread on instance `bean` (companion), `thread.send` |
|
||||
| S3b | through a fake harness | runs: the fake harness receives it and its reply is stored in her thread, `in_reply_to` her message |
|
||||
| S3c | stores a reply whose cites verify | waits on 216 (C2-05a lookups) and 217 (C2-06 guard and page store) |
|
||||
| S4 | refuses a playground cite with `"as": "canon"` | waits on 217 (`playground-cited-as-canon`) |
|
||||
| S5a | holds a force-push as `library-diverged` | runs: refused, served unchanged, `diverged.commit` is the forced tip, the refusal recorded |
|
||||
| S5b | | runs: `accept` of a stale commit refuses `library-accept-stale`, `accept` of the tip serves it |
|
||||
| S6 | fixture-grant write, one commit with the trailer, notice in her thread | waits on 283, 285, 286, 288 |
|
||||
| S7 | story-path write refuses `library-write-story-path`, ref unchanged | waits on 283, 288 |
|
||||
| S8 | her revert lands as one commit | waits on 288 |
|
||||
| S9 | her `library.file.save` lands as one commit, her `editor` as author | waits on 283, 288 |
|
||||
| S10 | the Console renders the reply and the notice | waits on 220, 225 |
|
||||
|
||||
Row notes:
|
||||
|
||||
- Sage's in-flight list was 216, 217, 220, 225, 226, 283, 285 and 286. Rows 284, 287, 288 and 289 also
|
||||
touch these steps, and the brief's "After" names the Q226 rows (told Sage in gizmoduck-0419). I mark
|
||||
a step with the rows its code needs, not every row that touches it:
|
||||
- 288 (C2-02) is the write verb, the notice, the revert and `library.file.save`, so S6-S9 all name it.
|
||||
- 287 (C2-21, the grant executor) isn't named on S6, because the brief asks for a fixture grant.
|
||||
- 284 (C2-05c) adds supporting docs to the lookups. S3c cites canon, so it waits on 216 and 217 only.
|
||||
- 289 (C2-11b) is the Console library page. S9 is the verb, which 288 owns. If Dewey reads S9 as the
|
||||
page, 289 joins its list.
|
||||
- 226 (C2-13, install script and precheck) gates no step. A scratch root isn't an install.
|
||||
- PRD 1.17 (row 308) made story files DOCX. The brief predates it and the fixture tree is Markdown.
|
||||
S1-S5 read that tree, which is what the landed reader takes. S6-S9 take the file shape 288 lands with.
|
||||
|
||||
## Choices
|
||||
|
||||
- One driver process, not one per step: the fake Gitea's port and CA would change with each process,
|
||||
and the library entry's remote is fixed at bus start.
|
||||
- The bus timer stays on with a one-hour period, since `startBroker` needs `librarySync` to carry the
|
||||
CA's `command`. Its pass at start can hold the mirror, so a step's sync retries
|
||||
`library-sync-busy` for up to about 10 s.
|
||||
- The refusal check opens the bus store read-only (`node:sqlite`, `readOnly: true`). The runtime
|
||||
hands back no store, and no bus verb lists the events.
|
||||
- Git in the driver runs with `GIT_CONFIG_GLOBAL=/dev/null` and `GIT_CONFIG_NOSYSTEM=1`, so a caller's
|
||||
hooks, signing or credential helper can't reach it. `helpers.mjs` `git` already passes the
|
||||
identity and `core.hooksPath=/dev/null`.
|
||||
|
||||
## Carry for Dewey
|
||||
|
||||
- The launcher in the path waits for C2-06's fake harness (row 217). Sage accepted both stand-ins,
|
||||
labelled, in sage-3608. When 217 lands, S3b moves to its fake harness behind the launcher.
|
||||
|
||||
## Not yet run
|
||||
|
||||
Darkwing's re-pin window holds the slot (sage-3581), so as of this draft no node ran on it: no
|
||||
`node --check`, no driver run, no suite. `bash -n scripts/test-case2.sh` passes. The first run waits for
|
||||
Sage's CLEAR, and the runs go below.
|
||||
|
||||
## Runs
|
||||
@@ -0,0 +1,124 @@
|
||||
# Row 217 (Case 2 C2-06): build notes
|
||||
|
||||
Brief: docs/plans/2026-10-20_case2-rows.md, section C2-06, at base 45dca69f6.
|
||||
Builder: Launchpad until 2026-10-23 19:09Z. Then Mos moved coding to Codex seats (mos-1500, Q245) and
|
||||
the row went to Beakley on beakley/row217.
|
||||
|
||||
## State at handoff
|
||||
|
||||
All code in this commit is UNTESTED. No node, no test and no mutant has run on it. No test file is written.
|
||||
`packages/harness/src/runner.mjs` is not started.
|
||||
|
||||
Done, untested:
|
||||
- `packages/library/src/limits.mjs` (new, pure): `REPLY_LIMITS`, `resolveReplyLimits(read)` gives
|
||||
`{requireCitation, citationsPerReply, fallback, unreadable}`, `checkReplyLimits(value)` gives a clone or null.
|
||||
An unreadable business file forces `requireCitation` true. A key falls back to its default when
|
||||
`replyLimits` is not an object or the key holds an invalid value.
|
||||
- `packages/library/src/guard.mjs` (new): `GUARD_CODES` in design 5.2's table order, `FIXED_TEXTS`,
|
||||
`WEB_CITE_DEFINITIONS`, `REASON_MAX` (300), `GuardError`, `parseReply`, `checkReply`, `libraryReader`,
|
||||
`lastCommits`, `guardReply`.
|
||||
- `packages/library/src/index.mjs`: exports for both.
|
||||
- `packages/business/src/business.mjs`: `replyLimits` is a known top key. The validated output doesn't carry it.
|
||||
- `packages/business/src/organization.mjs`: the M11 fence `replyLimitsFence(old, document, actor)` compares
|
||||
against `old.human`, refuses the tester and any `<definition>.<instance>` of `old.roles`, and is called for
|
||||
both `document` and `written`. `setReplyLimits` and `readRaw` are exported.
|
||||
- `packages/bus/src/broker.mjs`: the guard call, the page store, the check event, `library.limits.set`,
|
||||
`library.status` gains `replyLimits`, `role.claim` gains `library`, `thread.messages` gains `check` and `last`.
|
||||
- `packages/bus/src/server.mjs`, `client.mjs`: `BusError.reason` (sage-3599).
|
||||
- `packages/bus/src/runtime.mjs`: guard deps and the `library.limits.set` dispatch.
|
||||
- `packages/cli/src/limits.mjs`: `limits replies --file LIMITS`.
|
||||
|
||||
Left:
|
||||
- `packages/harness/src/runner.mjs`: the C2-07 runner work (see "Runner plan" below).
|
||||
- `packages/cli/README.md`: a line for `limits replies`.
|
||||
- Every test in the brief and the rulings below, then the mutants.
|
||||
- The rebases onto rows 283, 216 and 285, with the drift checks below.
|
||||
- This file's gate results and the manifest.
|
||||
|
||||
## Rulings in force
|
||||
|
||||
sage-3595:
|
||||
1. Supporting scope: a stub classify test now. Before in-review, rebase onto row 283's landing
|
||||
(honker/row283-build bd2c1c4ea when ruled) and add two real-fixture tests: a supporting cite with
|
||||
`as: "canon"` refuses `playground-cited-as-canon`, and the stored check result names `supporting`.
|
||||
2. runtime.mjs is approved for the guard wiring and the one `library.limits.set` branch. List its hunks here,
|
||||
name it in the manifest, and drift-check it after the 216 and 285 rebases.
|
||||
3. The check result is an `action.allowed` event with `action: "message.checked"`. The events kind CHECK at
|
||||
`packages/bus/src/migrations/3b.sql:9` has no `message.checked`, so a new kind would be a schema step.
|
||||
The event and the message INSERT share one transaction: a mutant that splits them must fail.
|
||||
4. readLibrary at the cited commit stands in for the C2-05a index. On the 216 rebase, compare 216's index
|
||||
set with readLibrary's for the fixture and record the result here.
|
||||
5. The retry is for a library business only. Tests: a JSON body between 32,768 characters and 64 KiB gets
|
||||
the retry; outside a library business nothing changes.
|
||||
6. Old `role.claim` pins stay as they are.
|
||||
7. `render.mjs:6` and `render.test.mjs:58` belong to row 225. Leave them.
|
||||
8. `thread.messages` rows that carry `check` get `last` per path cite: the file's last commit at the served
|
||||
tip, one git call per distinct path per read. Test with a fixture file committed after the cite.
|
||||
|
||||
sage-3599: `BusError` gets an optional `reason`. Only the citation guard's refusals set it. The envelope
|
||||
carries `reason` only when it is set. It is the guard's own text, never a quote, file text, url or a path
|
||||
other than the cited one, at most 300 characters. Tests: a guard refusal reaches the runner with code and
|
||||
reason; a refusal without a reason has an envelope byte-identical to today's, checked against an existing
|
||||
pin; a mutant that drops the client.mjs pass-through fails.
|
||||
|
||||
sage-3602: store `file` per path cite from the same git log call as the trailer. Test with a cited commit
|
||||
that didn't touch the file, so `file` differs from the cited commit, and `last` equals `file`.
|
||||
|
||||
## The check-result contract with row 225 (launchpad-0118, launchpad-0120, morgana-0200, sage-3596)
|
||||
|
||||
- Stored on the `message.checked` event: `{citations: [...]}` in body order.
|
||||
- A path cite: `{scope, agent, file}`. `scope` is the classify scope. `agent` is the cite's file's last
|
||||
commit's `Mosaic-Agent` trailer at the cited commit, or null. `file` is that commit's 40-hex id, or null.
|
||||
- A url cite: `{scope: "web", agent: null}`.
|
||||
- On `thread.messages`, each path cite gains `last`: the file's last commit at the served tip (40-hex or null).
|
||||
The Console compares `last` with `file`.
|
||||
- Rows without `check` are unchanged. No `changed` boolean. The webui passes rows through unchanged.
|
||||
|
||||
## Hunks outside the brief's file list
|
||||
|
||||
- `packages/bus/src/runtime.mjs`: imports `setReplyLimits`; the library deps gain `guard`, `replyLimits`
|
||||
(organizationWriter `readRaw`, or `{readable: false}` with no organization), `last` and `GuardError`;
|
||||
the organization dispatch gains `library.limits.set`.
|
||||
- `packages/bus/src/server.mjs`: a `refusal(e, other)` helper above serve's doc comment builds every
|
||||
BusError envelope and adds `reason` only when set. The synchronous request path accepts a Promise result
|
||||
(the guard is async), with a 120 s timeout, and releases `own` when it settles.
|
||||
- `packages/bus/src/client.mjs`: `new BusError(r.error, reason)` with `reason` a string cut at 300.
|
||||
|
||||
## Decisions and their tradeoffs
|
||||
|
||||
- The guard runs async outside the SQL transaction, then re-enters request() through `#guardPass`, so
|
||||
message.send's insert and the check event stay one synchronous transaction.
|
||||
- The guard runs before message.send's own checks, so a guard code can come before an authority code.
|
||||
- The check event id is derived from the message id (a UUID-shaped sha256), so `thread.messages` finds it
|
||||
through the unique index on `events.id`.
|
||||
- With no organization configured, `replyLimits` reads as unreadable, so it fails closed.
|
||||
- With more than one library project, the guard uses the first in the business file's order.
|
||||
- A library business without guard deps refuses guarded sends with `project-not-configured`.
|
||||
- The page store is per cap: 20 pages of 200 KB, kept only in a library business, dropped at session
|
||||
delete or disconnect and at run end.
|
||||
- The CLI `replies` verb reads the revision itself from `limits`.
|
||||
- Guard reasons never carry a url or a quote (sage-3599).
|
||||
|
||||
## Runner plan (not built)
|
||||
|
||||
`role.claim`'s reply is discarded at `runner.mjs:462`; keep it and read `library === true`. In a library
|
||||
business, at the reply site (`runner.mjs:582-587`):
|
||||
- Fixed texts `(the session gave no answer)` and `(the turn failed)`; the reason stays in the run log.
|
||||
A fixed text goes with no note, and `unslop.sent()` is not called, so the note waits.
|
||||
- A body that parses as a JSON object with a string `text` gets `unslop.note()` appended to `text`, is
|
||||
serialized again and is never clipped. A plain body keeps `clip(body) + unslop.note()`.
|
||||
- On a refusal whose code is in `GUARD_CODES`, or `request-too-large` from the client, or the broker's
|
||||
`invalid-request` from `string(a.body, 32768)` for a body over 32,768 characters (ruling 5; the runner
|
||||
can tell it by the body length it sent), run one new turn in the same
|
||||
session naming the code and `err.reason`, or the 64 KiB limit. Bracket it with usage like other turns.
|
||||
- After the retry: a table code sends `(no answer passed the citation check)`, too large sends
|
||||
`(the answer was too long to send)`. One retry per answer, whatever the cause.
|
||||
- `runTurn`'s output buffer holds up to 4 * BODY_MAX characters, so a reply over 64 KiB can reach the send.
|
||||
- Outside a library business nothing changes.
|
||||
|
||||
## Mutants planned
|
||||
|
||||
- The M11 writer check removed.
|
||||
- `document.human` compared instead of `old.human`.
|
||||
- The check event split from the insert transaction.
|
||||
- The client.mjs reason pass-through dropped.
|
||||
+134
-7
@@ -203,15 +203,26 @@ const canonical = (x) =>
|
||||
Number.isFinite(Date.parse(x)) &&
|
||||
new Date(x).toISOString() === x;
|
||||
export class BusError extends Error {
|
||||
constructor(code) {
|
||||
// C2-06 (sage-3599): only the citation guard's refusals carry a reason, and the envelope passes it on.
|
||||
constructor(code, reason) {
|
||||
super(code);
|
||||
this.code = code;
|
||||
if (reason !== undefined) this.reason = reason;
|
||||
}
|
||||
}
|
||||
const fail = (code) => {
|
||||
throw new BusError(code);
|
||||
};
|
||||
const object = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
|
||||
// C2-06 (design 5.2, B7): the page store keeps a session's last 20 broker fetches, 200 KB of text each.
|
||||
const PAGE_COUNT = 20;
|
||||
const PAGE_BYTES = 200 * 1024;
|
||||
// C2-06: the guard's check event takes an id derived from the message's, so a thread read finds it
|
||||
// through the unique index on events.id (there is none on subject).
|
||||
const checkEventId = (mid) => {
|
||||
const h = createHash('sha256').update('message.checked:' + mid).digest('hex');
|
||||
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
|
||||
};
|
||||
const text = (x, max = 4096) => typeof x === 'string' && x.length > 0 && x.length <= max && !x.includes('\0');
|
||||
const id = (x) => text(x, 160) && /^[a-zA-Z0-9][a-zA-Z0-9_.:/-]*$/.test(x);
|
||||
function keys(x, allowed, required = []) {
|
||||
@@ -389,6 +400,11 @@ export class Broker {
|
||||
#credentialVerbs;
|
||||
#project;
|
||||
#library;
|
||||
// C2-06 (design 5.2, B7): cap -> the session's kept web.fetch pages, in a library business only.
|
||||
// In memory, dropped with the session.
|
||||
#pages = new Map();
|
||||
// C2-06: { s, check } while a guarded message.send or a thread.messages re-enters request().
|
||||
#guardPass = null;
|
||||
#usage;
|
||||
#limits;
|
||||
#harnessOn;
|
||||
@@ -1403,7 +1419,10 @@ export class Broker {
|
||||
this.#launcherReleased.delete(key);
|
||||
this.#accessRevoked.delete(key);
|
||||
for (const [cap, s] of this.#sessions)
|
||||
if (!s.human && !s.reader && s.business === record.business && s.run === record.run) this.#sessions.delete(cap);
|
||||
if (!s.human && !s.reader && s.business === record.business && s.run === record.run) {
|
||||
this.#sessions.delete(cap);
|
||||
this.#pages.delete(cap);
|
||||
}
|
||||
return this.#store.transaction(() => {
|
||||
const h = this.#own(session);
|
||||
const released = h?.op === 'claim' && h.holder_run === session.run;
|
||||
@@ -1485,6 +1504,7 @@ export class Broker {
|
||||
}
|
||||
disconnect(cap) {
|
||||
this.#sessions.delete(cap);
|
||||
this.#pages.delete(cap);
|
||||
}
|
||||
identity(cap) {
|
||||
const s = this.#session(cap);
|
||||
@@ -4666,10 +4686,19 @@ export class Broker {
|
||||
return this.#requestProjectFile(s, { verb: request.verb, args: structuredClone(args) });
|
||||
if (LIBRARY_VERBS.includes(request.verb))
|
||||
return this.#requestLibrary(s, { verb: request.verb, args: structuredClone(args) });
|
||||
// C2-06 (design 5.2): an agent's message to the human in a library business passes the citation
|
||||
// guard first, outside any transaction, and thread.messages adds each path cite's `last`. Both
|
||||
// then re-enter request() for the synchronous path below.
|
||||
if (this.#guardPass?.s !== s) {
|
||||
if (request.verb === 'message.send' && this.#guarded(s, args.to))
|
||||
return this.#guardedSend(cap, s, request, structuredClone(args));
|
||||
if (request.verb === 'thread.messages' && this.#libraryProject(s.business) && this.#library?.last)
|
||||
return this.#withLast(cap, s, request);
|
||||
}
|
||||
if (CREDENTIAL_VERBS.includes(request.verb)) return this.#requestCredential(s, request.verb, structuredClone(args));
|
||||
if (request.verb === 'instance.hire' || request.verb === 'instance.fire')
|
||||
return this.#chiefLifecycle(s, request.verb, structuredClone(args));
|
||||
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set"].includes(request.verb)) {
|
||||
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set", "library.limits.set"].includes(request.verb)) {
|
||||
// Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose
|
||||
// file lists organization.change. It takes the same keys, runs the same write path and
|
||||
// logs the same receipt; only the actor differs: the role definition and instance.
|
||||
@@ -4715,6 +4744,12 @@ export class Broker {
|
||||
keys(args, VETO_KEYS, VETO_KEYS);
|
||||
this.#human(s);
|
||||
this.#mayHuman(s, "veto.set", args.name);
|
||||
}
|
||||
// C2-06 M11: the human's replyLimits verb. The tester is refused by the gate above and an agent
|
||||
// session by human-required; the writer's fence refuses any other actor.
|
||||
else if (request.verb === "library.limits.set") {
|
||||
keys(args, ["revision", "replyLimits"], ["revision", "replyLimits"]);
|
||||
this.#human(s);
|
||||
} else {
|
||||
keys(args, ORGANIZATION_KEYS, ["revision"]);
|
||||
// Row 194 (D2.4, B7): the chief can't change a kept instance's model, as that replaces it at
|
||||
@@ -5138,7 +5173,14 @@ export class Broker {
|
||||
const context = { business: this.#business(s.business), project: args.project };
|
||||
let result;
|
||||
if (verb === 'library.file') result = await this.#library.readFile({ ...context, path: args.path });
|
||||
else if (verb === 'library.status') result = await this.#library.status(context);
|
||||
else if (verb === 'library.status') {
|
||||
result = await this.#library.status(context);
|
||||
// C2-06: the reply limits in force, read from the file now, and the keys that fell back.
|
||||
if (this.#library.replyLimits) {
|
||||
const { requireCitation, citationsPerReply, fallback, unreadable } = this.#library.replyLimits(s.business);
|
||||
result = { ...result, replyLimits: { requireCitation, citationsPerReply, fallback, problem: unreadable ? 'reply-limits-unreadable' : null } };
|
||||
}
|
||||
}
|
||||
else {
|
||||
// A fetch that did not move served still answers with the code, so the refusal is recorded.
|
||||
const { state, code } = await this.#library.sync(context, { accept: args.accept ?? null });
|
||||
@@ -5152,6 +5194,78 @@ export class Broker {
|
||||
throw this.#refused(s, Failure && error instanceof Failure ? new BusError(error.code) : error, request);
|
||||
}
|
||||
}
|
||||
// C2-06: the business's library project, the first in file order, or null.
|
||||
#libraryProject(business) {
|
||||
return Object.entries(this.#business(business).projects).find(([, p]) => p.kind === 'library')?.[0] ?? null;
|
||||
}
|
||||
// C2-06 (design 5.2): every agent session's message to the human in a business with a library project
|
||||
// is guarded, whatever the sender's definition.
|
||||
#guarded(s, to) {
|
||||
return to === 'human' && !s.human && !s.tester && !s.user && !s.reader && !s.console && this.#libraryProject(s.business) !== null;
|
||||
}
|
||||
#reenter(cap, s, request, check) {
|
||||
this.#guardPass = { s, check };
|
||||
try {
|
||||
return this.request(cap, request);
|
||||
} finally {
|
||||
this.#guardPass = null;
|
||||
}
|
||||
}
|
||||
// As #requestProject: the git reads run outside any SQL transaction. A guard refusal is recorded with
|
||||
// its code and reaches the caller with the guard's reason; the send then runs, and stores the check
|
||||
// result in its own transaction.
|
||||
async #guardedSend(cap, s, request, args) {
|
||||
let check;
|
||||
try {
|
||||
this.#store.transaction(() => this.#agent(s));
|
||||
if (!this.#library?.guard || !this.#library?.replyLimits) fail('project-not-configured');
|
||||
if (typeof args.body !== 'string') fail('invalid-request');
|
||||
const business = this.#business(s.business);
|
||||
check = await this.#library.guard({ business, project: this.#libraryProject(s.business) }, args.body, {
|
||||
limits: this.#library.replyLimits(s.business),
|
||||
definition: business.roles[s.role]?.definition ?? null,
|
||||
pages: this.#pages.get(cap) ?? [],
|
||||
});
|
||||
} catch (e) {
|
||||
const Guard = this.#library?.GuardError;
|
||||
throw this.#refused(s, Guard && e instanceof Guard ? new BusError(e.code, e.reason) : e, request);
|
||||
}
|
||||
return this.#reenter(cap, s, request, check);
|
||||
}
|
||||
// C2-06 (sage-3595, morgana-0200): each path cite of a checked row gains `last`, the file's last commit
|
||||
// at the served tip, one git call per distinct path. Rows without `check` are unchanged.
|
||||
async #withLast(cap, s, request) {
|
||||
const rows = this.#reenter(cap, s, request, null);
|
||||
if (!rows.some((m) => m.check)) return rows;
|
||||
const cites = (m) => {
|
||||
try {
|
||||
const c = JSON.parse(m.body).citations;
|
||||
return Array.isArray(c) ? c : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
let last = new Map();
|
||||
try {
|
||||
const paths = rows.filter((m) => m.check).flatMap((m) => cites(m).filter((c) => typeof c?.path === 'string').map((c) => c.path));
|
||||
last = await this.#library.last({ business: this.#business(s.business), project: this.#libraryProject(s.business) }, paths);
|
||||
} catch {
|
||||
last = new Map();
|
||||
}
|
||||
return rows.map((m) => {
|
||||
if (!m.check) return m;
|
||||
const body = cites(m);
|
||||
return { ...m, check: { ...m.check, citations: m.check.citations.map((c, i) =>
|
||||
c.scope === 'web' ? c : { ...c, last: last.get(body[i]?.path) ?? null }) } };
|
||||
});
|
||||
}
|
||||
// C2-06 (B7): a fetch's text, kept for url cites in a library business only.
|
||||
#keepPage(cap, s, url, page) {
|
||||
if (!this.#sessions.has(cap) || !this.#libraryProject(s.business) || typeof page?.text !== 'string') return;
|
||||
const text = Buffer.from(page.text).subarray(0, PAGE_BYTES).toString();
|
||||
const kept = { url, finalUrl: typeof page.finalUrl === 'string' ? page.finalUrl : null, text };
|
||||
this.#pages.set(cap, [...(this.#pages.get(cap) ?? []), kept].slice(-PAGE_COUNT));
|
||||
}
|
||||
// The eight task verbs reach the trusted S3 handler here. It runs outside any transaction, gets the
|
||||
// cap to authorize and record through authorize() and recordTask(); refusals are recorded as in request().
|
||||
async requestTask(cap, request, handler) {
|
||||
@@ -5235,6 +5349,7 @@ export class Broker {
|
||||
throw refuse(new BusError('web-failed'));
|
||||
}
|
||||
complete(out.summary);
|
||||
if (verb === 'web.fetch') this.#keepPage(cap, s, args.url, out.result);
|
||||
return { ...out.result, untrusted: true, notice: WEB_UNTRUSTED, source: verb };
|
||||
}
|
||||
// Row 210 (#1707, design D5): host.run, one sudo command on a registered host over ssh, for a
|
||||
@@ -5361,7 +5476,7 @@ export class Broker {
|
||||
by,
|
||||
);
|
||||
this.#routeSession(s.session);
|
||||
return { role: s.role, run: s.run, thread: s.thread };
|
||||
return { role: s.role, run: s.run, thread: s.thread, ...(this.#libraryProject(s.business) ? { library: true } : {}) };
|
||||
}
|
||||
if (
|
||||
this.#store.get(
|
||||
@@ -5385,7 +5500,8 @@ export class Broker {
|
||||
by,
|
||||
);
|
||||
this.#routeWaiting(s.business, s.role);
|
||||
return { role: s.role, run: s.run };
|
||||
// C2-06 (C2-07): the runner's switch to the library texts and retry. Absent elsewhere.
|
||||
return { role: s.role, run: s.run, ...(this.#libraryProject(s.business) ? { library: true } : {}) };
|
||||
}
|
||||
case 'role.release': {
|
||||
keys(a, []);
|
||||
@@ -5749,6 +5865,10 @@ export class Broker {
|
||||
if (this.#userThread(s) && to === 'human') fail('user-thread-human');
|
||||
this.#checkMessaging(s.business, this.#sender(s), testerThread && to === 'tester' ? 'human' : to);
|
||||
string(a.body, 32768);
|
||||
// C2-06: a guarded send is stored only through #guardedSend's pass, with its check result.
|
||||
const guarded = this.#guarded(s, to);
|
||||
if (guarded && this.#guardPass?.s !== s) fail('storage-refused');
|
||||
const check = guarded ? this.#guardPass.check : null;
|
||||
if (
|
||||
a.class !== undefined &&
|
||||
![
|
||||
@@ -5795,6 +5915,9 @@ export class Broker {
|
||||
a.corrects ?? null,
|
||||
a.body,
|
||||
);
|
||||
// C2-06: the stored check result shares the message's transaction (sage-3595 item 3). No new
|
||||
// event kind, which would be a schema step: the events kind CHECK is migrations/3b.sql:9.
|
||||
if (check) this.#event(s, 'action.allowed', { action: 'message.checked', message: mid, check }, mid, checkEventId(mid));
|
||||
const cancelled = this.#route(s.business, mid, to, thread);
|
||||
const request = s.human
|
||||
? this.#event(s, 'human.input', { kind: 'instruction', message: mid }, mid)
|
||||
@@ -6414,11 +6537,15 @@ export class Broker {
|
||||
)?.seq;
|
||||
if (after === undefined) fail('message-not-found');
|
||||
}
|
||||
// C2-06 (row 225, morgana-0198): a row the citation guard checked carries the stored result.
|
||||
return this.#store.all(
|
||||
`SELECT m.* FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.session=? AND m.seq>? ORDER BY m.seq LIMIT ${limit}`,
|
||||
session,
|
||||
after,
|
||||
);
|
||||
).map((m) => {
|
||||
const e = this.#store.get("SELECT body FROM events WHERE id=? AND kind='action.allowed' AND subject=?", checkEventId(m.id), m.id);
|
||||
return e ? { ...m, check: JSON.parse(e.body).check } : m;
|
||||
});
|
||||
}
|
||||
// Row 206 (193b2, #1704, design 5.3): the owner of a thread shares it read-only, or ends a share.
|
||||
// A user's collaborator grant must still cover the thread, and a user target needs a live grant
|
||||
|
||||
@@ -41,7 +41,8 @@ export class Client {
|
||||
const r = JSON.parse(input.toString('utf8'));
|
||||
if (r.ok === true) end(null, r.result);
|
||||
else if (r.ok === false && typeof r.error === 'string' && /^[a-z-]{1,64}$/.test(r.error))
|
||||
end(new BusError(r.error));
|
||||
// C2-06 (sage-3599): the citation guard's reason, when the envelope has one.
|
||||
end(new BusError(r.error, typeof r.reason === 'string' ? r.reason.slice(0, 300) : undefined));
|
||||
else end(new BusError('invalid-response'));
|
||||
} catch {
|
||||
end(new BusError('invalid-response'));
|
||||
|
||||
@@ -14,7 +14,7 @@ import { publishRemote, workingRepo, WorkingError } from '../../seat/src/working
|
||||
import { createLoader, hasModule } from '../../plugins/src/loader.mjs';
|
||||
import { pluginSettings } from '../../business/src/plugin-settings.mjs';
|
||||
import { instanceSettings } from '../../business/src/instance-settings.mjs';
|
||||
import { organizationWriter, OrganizationError } from '../../business/src/organization.mjs';
|
||||
import { organizationWriter, OrganizationError, setReplyLimits } from '../../business/src/organization.mjs';
|
||||
import { credentialVerbs } from './credential-verbs.mjs';
|
||||
import { loadBusiness } from '../../business/src/business.mjs';
|
||||
import { loadRole } from '../../business/src/role.mjs';
|
||||
@@ -305,6 +305,13 @@ export async function startBroker({
|
||||
status: context => libraryModule.libraryStatus({ ...context, dataRoot }),
|
||||
sync: (context, { accept }) => processing.work(() => libraryModule.sync({ ...context, dataRoot }, { ...syncOptions(context.business), accept })),
|
||||
LibraryError: libraryModule.LibraryError,
|
||||
// C2-06: the citation guard, the reply limits read from the business file at each check (none
|
||||
// configured reads as unreadable, so requireCitation is true), and thread.messages' `last`.
|
||||
guard: (context, body, options) => libraryModule.guardReply({ ...context, dataRoot }, body, options),
|
||||
replyLimits: id => libraryModule.resolveReplyLimits(organization
|
||||
? organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }).readRaw(id) : { readable: false }),
|
||||
last: (context, paths) => libraryModule.lastCommits({ ...context, dataRoot }, paths),
|
||||
GuardError: libraryModule.GuardError,
|
||||
},
|
||||
project: {
|
||||
readSpec: context => projectModule.readSpec({ ...context, dataRoot }),
|
||||
@@ -334,6 +341,8 @@ export async function startBroker({
|
||||
else if(verb==='plugins.set')result=plugins.set(id,args,actor);
|
||||
// Row 194 (#1692, V6): the human's keep and noHire lists, through the instance writer.
|
||||
else if(verb==='veto.set')result=instances.veto(id,args,actor);
|
||||
// C2-06 M11: the human's replyLimits, through the organization writer's fence.
|
||||
else if(verb==='library.limits.set')result=setReplyLimits(organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir}),id,args,actor);
|
||||
// Row 194 (D2.1, D2.2): the chief's hire and fire, with the reason in the change context.
|
||||
else if(verb==='instance.hire')result=instances.hire(id,args,{actor,context});
|
||||
else if(verb==='instance.fire')result=instances.fire(id,{name:args.name},{actor,context});
|
||||
|
||||
@@ -7,6 +7,11 @@ const LIMIT = 65536;
|
||||
// Row 148: a document save may carry up to 1 MiB of text. Only a request that names that verb near
|
||||
// its start (the proof object comes first) may pass LIMIT; the parsed verb is checked again below.
|
||||
const SAVE_VERB_AT_START = new RegExp(`"verb":"${PROJECT_SAVE_VERB.replaceAll('.', '\\.')}"`);
|
||||
// The refusal envelope. C2-06 (sage-3599): `reason` is added only when the error carries one, which
|
||||
// only the citation guard's 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 };
|
||||
// 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
|
||||
@@ -15,6 +20,7 @@ const SAVE_VERB_AT_START = new RegExp(`"verb":"${PROJECT_SAVE_VERB.replaceAll('.
|
||||
// host-not-configured.
|
||||
// A request carries exactly one of cap, human (row S4), tester (row 110), user (FED R3) and console
|
||||
// (row 252) proof.
|
||||
|
||||
export async function serve({ broker, path, authenticateHuman = null, authenticateTester = null, authenticateUser = null, authenticateConsole = null, peerReader = null, timeout = 5000, tasks = null, web = null, host = null }) {
|
||||
try {
|
||||
lstatSync(path);
|
||||
@@ -96,7 +102,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
|
||||
.requestTask(cap, { verb: r.verb, args: r.args ?? {} }, tasks.handle)
|
||||
.then(
|
||||
(result) => finish({ ok: true, result }),
|
||||
(e) => finish({ ok: false, error: e instanceof BusError ? e.code : 'adapter-failed' }),
|
||||
(e) => finish(refusal(e, 'adapter-failed')),
|
||||
)
|
||||
.finally(() => {
|
||||
if (own) release();
|
||||
@@ -114,7 +120,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
|
||||
.requestWeb(cap, { verb: r.verb, args: r.args ?? {} }, service, { onAdmit: () => socket.setTimeout(service.timeout) })
|
||||
.then(
|
||||
(result) => finish({ ok: true, result }),
|
||||
(e) => finish({ ok: false, error: e instanceof BusError ? e.code : 'web-failed' }),
|
||||
(e) => finish(refusal(e, 'web-failed')),
|
||||
)
|
||||
.finally(() => {
|
||||
if (own) release();
|
||||
@@ -130,7 +136,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
|
||||
.requestHost(cap, { verb: r.verb, args: r.args ?? {} }, host, { onAdmit: (ms) => socket.setTimeout(ms + 10000) })
|
||||
.then(
|
||||
(result) => finish({ ok: true, result }),
|
||||
(e) => finish({ ok: false, error: e instanceof BusError ? e.code : 'host-failed' }),
|
||||
(e) => finish(refusal(e, 'host-failed')),
|
||||
)
|
||||
.finally(() => {
|
||||
if (own) release();
|
||||
@@ -153,14 +159,26 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
|
||||
socket.setTimeout(120_000);
|
||||
Promise.resolve().then(() => broker.request(cap, { verb: r.verb, args: r.args ?? {} })).then(
|
||||
result => { if (!socket.destroyed) finish({ ok: true, result }); },
|
||||
e => { if (!socket.destroyed) finish({ ok: false, error: e instanceof BusError ? e.code : 'invalid-envelope' }); },
|
||||
e => { if (!socket.destroyed) finish(refusal(e, 'invalid-envelope')); },
|
||||
).finally(() => { if (own) release(); });
|
||||
return;
|
||||
}
|
||||
const result = broker.request(cap, { verb: r.verb, args: r.args ?? {} });
|
||||
// C2-06: a guarded message.send and a library business's thread.messages read Git first, so
|
||||
// the broker answers them with a promise. Every other verb stays synchronous.
|
||||
if (result instanceof Promise) {
|
||||
const own = transient;
|
||||
transient = null;
|
||||
socket.setTimeout(120_000);
|
||||
result.then(
|
||||
value => { if (!socket.destroyed) finish({ ok: true, result: value }); },
|
||||
e => { if (!socket.destroyed) finish(refusal(e, 'invalid-envelope')); },
|
||||
).finally(() => { if (own) release(); });
|
||||
return;
|
||||
}
|
||||
finish({ ok: true, result });
|
||||
} catch (e) {
|
||||
finish({ ok: false, error: e instanceof BusError ? e.code : 'invalid-envelope' });
|
||||
finish(refusal(e, 'invalid-envelope'));
|
||||
} finally {
|
||||
if (transient) release();
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
|
||||
// How long a thread's session stays up with no turn before the launcher stops
|
||||
// it, when launch.threadIdleSeconds is absent (REQ-THREAD-2, row 121).
|
||||
export const THREAD_IDLE_SECONDS = 900;
|
||||
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired"];
|
||||
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired", "replyLimits"];
|
||||
|
||||
// The config directory: next to the system config, $MOSAIC_CONFIG or
|
||||
// ~/.config/mosaic-dev/config.json, the same rule mosaic-config.mjs uses.
|
||||
|
||||
@@ -8,6 +8,7 @@ import { join, dirname } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { businessFilePath, validateBusinessDocument } from "./business.mjs";
|
||||
import { requireId, sha256, canonicalJson } from "./util.mjs";
|
||||
import { checkReplyLimits } from "../../library/src/limits.mjs";
|
||||
|
||||
export class OrganizationError extends Error {
|
||||
constructor(code, message = code) {
|
||||
@@ -80,6 +81,33 @@ function changes(old, next, path = "") {
|
||||
}];
|
||||
}
|
||||
|
||||
// C2-06 M11: replyLimits is the human's alone. Any setter that changes it refuses unless the actor is
|
||||
// the business's human as the file stood before the write, and no agent or the tester under that
|
||||
// name. Compared as M9 compares projects, on the document and again on what lands on disk.
|
||||
function replyLimitsFence(old, document, actor) {
|
||||
if (canonicalJson(document?.replyLimits) === canonicalJson(old.replyLimits)) return;
|
||||
const agents = Object.entries(old.roles ?? {}).map(([instance, entry]) => `${entry?.definition}.${instance}`);
|
||||
if (actor === old.human && actor !== "tester" && !agents.includes(actor)) return;
|
||||
// The reason names each changed key and its value in force (the brief's M11).
|
||||
const plain = v => v !== null && typeof v === "object" && !Array.isArray(v);
|
||||
const before = old.replyLimits, after = document?.replyLimits;
|
||||
const named = plain(before) && plain(after)
|
||||
? [...new Set([...Object.keys(before), ...Object.keys(after)])].filter(k => canonicalJson(before[k]) !== canonicalJson(after[k]))
|
||||
.map(k => `replyLimits.${k} is ${canonicalJson(before[k]) ?? "unset"}`)
|
||||
: [`replyLimits is ${canonicalJson(before) ?? "unset"}`];
|
||||
fail("organization-reply-limits-human-only", `${named.join(", ").slice(0, 240)}; only the human changes it`);
|
||||
}
|
||||
|
||||
// C2-06: library.limits.set, the human's verb. It replaces the whole replyLimits object; a key left out
|
||||
// is its default. An unknown key or a value outside the table refuses invalid-request, and the write's
|
||||
// M11 fence refuses any actor but the human.
|
||||
export function setReplyLimits(writer, id, args, actor) {
|
||||
const replyLimits = checkReplyLimits(args?.replyLimits);
|
||||
if (replyLimits === null || typeof args?.revision !== "string") fail("invalid-request", "replyLimits takes requireCitation and citationsPerReply only");
|
||||
const { document } = writer.read(id);
|
||||
return writer.write(id, { ...document, replyLimits }, { revision: args.revision, actor });
|
||||
}
|
||||
|
||||
export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
const evidence = join(dir, "businesses", ".history");
|
||||
const logFile = id => join(evidence, id, "changes.jsonl");
|
||||
@@ -93,6 +121,19 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
return { document, revision: sha256(bytes) };
|
||||
}
|
||||
|
||||
// C2-06: the citation guard reads replyLimits fresh on every check, from the raw file, so a business
|
||||
// file that fails validation still yields its limits. { readable: false } when the file is missing,
|
||||
// unreadable, not JSON or not an object.
|
||||
function readRaw(id) {
|
||||
try {
|
||||
const document = JSON.parse(regular(businessFilePath(id, dir)));
|
||||
return document !== null && typeof document === "object" && !Array.isArray(document)
|
||||
? { readable: true, document } : { readable: false };
|
||||
} catch {
|
||||
return { readable: false };
|
||||
}
|
||||
}
|
||||
|
||||
function withLock(id, fn) {
|
||||
const parent = join(dir, "businesses");
|
||||
directory(evidence);
|
||||
@@ -191,6 +232,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
// mapping, including library playgrounds and hybrid universes.
|
||||
if (canonicalJson(document?.projects) !== canonicalJson(old.projects))
|
||||
fail('organization-projects-protected');
|
||||
replyLimitsFence(old, document, actor);
|
||||
validateBusinessDocument(document, file, { rolesDir });
|
||||
const bytes = JSON.stringify(document, null, 2) + "\n";
|
||||
// Check and log exactly what lands on disk, for every setter: a value JSON
|
||||
@@ -198,6 +240,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
const written = JSON.parse(bytes);
|
||||
if (canonicalJson(written.projects) !== canonicalJson(old.projects))
|
||||
fail('organization-projects-protected');
|
||||
replyLimitsFence(old, written, actor);
|
||||
validateBusinessDocument(written, file, { rolesDir });
|
||||
const after = sha256(bytes);
|
||||
const transaction = randomUUID();
|
||||
@@ -245,5 +288,5 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
});
|
||||
}
|
||||
|
||||
return { logFile, read, write, record, recover: id => withLock(id, () => recover(id)) };
|
||||
return { logFile, read, readRaw, write, record, recover: id => withLock(id, () => recover(id)) };
|
||||
}
|
||||
@@ -7,9 +7,10 @@ import { CliError } from "./errors.mjs";
|
||||
import { migrateLaunch } from "../../business/src/migrate-launch.mjs";
|
||||
|
||||
// Row 155 (#1649, Q169): a PATCH may carry `assistants`, the assistant's vars, tracker and definition.
|
||||
// C2-06 M11: `replies --file LIMITS` replaces replyLimits with the file's object, at the current revision.
|
||||
export const LIMITS_USAGE = "mosaic limits [--business ID] [--json] [--tester] | set --file PATCH [--business ID] | " +
|
||||
"reset PATH... [--business ID] | migrate --file ORGANIZATION --models MAPPING [--roles-dir DIR]; " +
|
||||
"PATCH keys: revision, limits, models, instances, reset, assistants";
|
||||
"reset PATH... [--business ID] | replies --file LIMITS [--business ID] | migrate --file ORGANIZATION --models MAPPING [--roles-dir DIR]; " +
|
||||
"PATCH keys: revision, limits, models, instances, reset, assistants; LIMITS keys: requireCitation, citationsPerReply";
|
||||
|
||||
export const DAILY_NOTE = "Daily quotas count only this stack's own use of the account, not its use anywhere else.";
|
||||
|
||||
@@ -67,8 +68,9 @@ export async function limitsCommand(args, io, callFor) {
|
||||
io.stdout.write(JSON.stringify(converted, null, 2) + "\n");
|
||||
return;
|
||||
}
|
||||
if (!["view", "set", "reset"].includes(verb) || values["--models"] || values["--roles-dir"] ||
|
||||
(verb !== "set" && values["--file"]) || (verb === "set" && !values["--file"]) ||
|
||||
const fileVerb = verb === "set" || verb === "replies";
|
||||
if (!["view", "set", "reset", "replies"].includes(verb) || values["--models"] || values["--roles-dir"] ||
|
||||
(!fileVerb && values["--file"]) || (fileVerb && !values["--file"]) ||
|
||||
(verb === "reset" && parsed.positional.length < 2)) {
|
||||
throw new CliError(LIMITS_USAGE, 4);
|
||||
}
|
||||
@@ -78,6 +80,11 @@ export async function limitsCommand(args, io, callFor) {
|
||||
// result's `tokens` lists each token limit: counted, not-enforced or check-failed.
|
||||
let result;
|
||||
if (verb === "set") result = await call("limits.set", JSON.parse(readFileSync(values["--file"], "utf8")));
|
||||
else if (verb === "replies") {
|
||||
const replyLimits = JSON.parse(readFileSync(values["--file"], "utf8"));
|
||||
const { revision } = await call("limits", {});
|
||||
result = await call("library.limits.set", { revision, replyLimits });
|
||||
}
|
||||
else if (verb === "reset") {
|
||||
const { revision } = await call("limits", {});
|
||||
result = await call("limits.set", { revision, reset: [parsed.positional.slice(1)] });
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
// C2-06 (design 5.2, rulings after r11 Q222, Q223 and Q226): the citation guard. The broker runs it
|
||||
// before it stores an agent's message to the human in a business with a library project. It reads
|
||||
// only the citations, never what the text says. Codes are design 5.2's table.
|
||||
import { git } from '../../project/src/git.mjs';
|
||||
import { classify } from './scope.mjs';
|
||||
import { listTree, readLibrary, readTexts, splitLines } from './reader.mjs';
|
||||
import { LibraryError } from './problems.mjs';
|
||||
import { mirrorPath, readSyncState } from './mirror.mjs';
|
||||
import { plain } from './limits.mjs';
|
||||
|
||||
export const GUARD_CODES = Object.freeze(['reply-invalid', 'citation-missing', 'citations-over-limit', 'citation-invalid',
|
||||
'playground-cited-as-canon', 'citation-cross-universe', 'citation-unfetched']);
|
||||
|
||||
// The runner's four fixed texts in a library business. The guard accepts exactly these bodies when
|
||||
// requireCitation is true; an agent that sends one gains nothing it couldn't say anyway.
|
||||
export const FIXED_TEXTS = Object.freeze({
|
||||
empty: '(the session gave no answer)',
|
||||
failed: '(the turn failed)',
|
||||
uncited: '(no answer passed the citation check)',
|
||||
tooLong: '(the answer was too long to send)',
|
||||
});
|
||||
|
||||
// Web cites are for these definitions only (design 5.2).
|
||||
export const WEB_CITE_DEFINITIONS = Object.freeze(['research', 'companion']);
|
||||
|
||||
// The reason reaches the runner (sage-3599): the guard's own text naming the check, the cite and the
|
||||
// limit, never a quote or file text, and at most REASON_MAX characters.
|
||||
export const REASON_MAX = 300;
|
||||
export class GuardError extends LibraryError {
|
||||
constructor(code, reason) { reason = reason.slice(0, REASON_MAX); super(code, reason); this.reason = reason; }
|
||||
}
|
||||
const refuse = (code, reason) => { throw new GuardError(code, reason); };
|
||||
|
||||
const SHA = /^[a-f0-9]{40}$/;
|
||||
|
||||
const LIBRARY_CITE = ['path', 'commit', 'lines', 'quote'];
|
||||
const WEB_CITE = ['url', 'quote'];
|
||||
const only = (value, required, optional = []) => plain(value)
|
||||
&& required.every(k => Object.hasOwn(value, k)) && Object.keys(value).every(k => required.includes(k) || optional.includes(k));
|
||||
const text = value => typeof value === 'string' && value.length > 0 && !value.includes('\0');
|
||||
|
||||
function libraryCite(c) {
|
||||
return only(c, LIBRARY_CITE, ['as']) && text(c.path) && c.path.length <= 4096 && typeof c.commit === 'string' && SHA.test(c.commit)
|
||||
&& Array.isArray(c.lines) && c.lines.length === 2 && c.lines.every(n => Number.isSafeInteger(n) && n >= 1) && c.lines[0] <= c.lines[1]
|
||||
&& text(c.quote) && (!Object.hasOwn(c, 'as') || c.as === 'canon');
|
||||
}
|
||||
const webCite = c => only(c, WEB_CITE) && text(c.url) && c.url.length <= 4096 && text(c.quote);
|
||||
|
||||
// A body is free text, or a JSON object {text, citations} that must fit the schema. Returns
|
||||
// { text, citations, cited } where `cited` says the body was the JSON form.
|
||||
export function parseReply(body) {
|
||||
let value;
|
||||
try { value = JSON.parse(body); } catch { return { text: body, citations: [], cited: false }; }
|
||||
if (!plain(value)) return { text: body, citations: [], cited: false };
|
||||
if (!only(value, ['text', 'citations']) || typeof value.text !== 'string' || !Array.isArray(value.citations))
|
||||
refuse('reply-invalid', 'a JSON object reply holds exactly "text", a string, and "citations", a list');
|
||||
value.citations.forEach((c, i) => {
|
||||
if (!libraryCite(c) && !webCite(c))
|
||||
refuse('reply-invalid', `citation ${i + 1} is neither {path, commit, lines, quote, as?} nor {url, quote}`);
|
||||
});
|
||||
return { text: value.text, citations: value.citations, cited: true };
|
||||
}
|
||||
|
||||
const squash = value => value.replace(/\s+/g, ' ').trim();
|
||||
|
||||
// Design 5.2's checks, in the table's order. `limits` comes from resolveReplyLimits. `library` reads
|
||||
// git for a library cite (libraryReader below). `web` is { allowed, pages } for this sender: whether
|
||||
// its definition may cite a url, and the session's kept fetches. Returns the stored check result,
|
||||
// { citations: [{ scope, agent, file }] } in body order (agreed with row 225, morgana-0198 and
|
||||
// morgana-0200; a url cite is { scope: 'web', agent: null }), or null for a
|
||||
// plain body. Throws GuardError.
|
||||
export async function checkReply(body, { limits, mapping, library, web }) {
|
||||
if (Object.values(FIXED_TEXTS).includes(body)) return null;
|
||||
const reply = parseReply(body);
|
||||
const { citations } = reply;
|
||||
if (limits.requireCitation && citations.length === 0)
|
||||
refuse('citation-missing', 'replyLimits.requireCitation is true, so a reply carries at least one citation');
|
||||
if (citations.length > limits.citationsPerReply)
|
||||
refuse('citations-over-limit', `replyLimits.citationsPerReply is ${limits.citationsPerReply}; the reply carries ${citations.length}`);
|
||||
if (!reply.cited) return null;
|
||||
const found = [];
|
||||
for (const [i, c] of citations.entries()) {
|
||||
if (!Object.hasOwn(c, 'path')) { found.push(null); continue; }
|
||||
const at = `citation ${i + 1} (${c.path})`;
|
||||
const where = classify(mapping, c.path);
|
||||
if (where.scope === 'unmapped') refuse('citation-invalid', `${at} names a path that isn't mapped`);
|
||||
const lines = await library.lines(c.commit, c.path);
|
||||
if (lines === null) refuse('citation-invalid', `${at} isn't an indexed file at a commit reachable from the served commit`);
|
||||
if (c.lines[1] > lines.length) refuse('citation-invalid', `${at} cites lines past the file's ${lines.length}`);
|
||||
const quote = squash(c.quote);
|
||||
if (!quote || !squash(lines.slice(c.lines[0] - 1, c.lines[1]).join('\n')).includes(quote))
|
||||
refuse('citation-invalid', `${at} quote isn't in lines ${c.lines[0]}-${c.lines[1]}`);
|
||||
found.push(where);
|
||||
}
|
||||
citations.forEach((c, i) => {
|
||||
if (c.as === 'canon' && found[i].scope !== 'canon')
|
||||
refuse('playground-cited-as-canon', `citation ${i + 1} (${c.path}) is ${found[i].scope}, not canon`);
|
||||
});
|
||||
const universes = [...new Set(found.filter(Boolean).map(w => w.universe))];
|
||||
if (universes.length > 1) {
|
||||
const hybrid = universes.find(u => mapping.universes[u]?.kind === 'hybrid');
|
||||
const sources = hybrid ? mapping.universes[hybrid].sources : [];
|
||||
if (!hybrid || !universes.every(u => u === hybrid || sources.includes(u)))
|
||||
refuse('citation-cross-universe', `the citations span universes ${universes.join(', ')}`);
|
||||
}
|
||||
citations.forEach((c, i) => {
|
||||
if (!Object.hasOwn(c, 'url')) return;
|
||||
if (!web.allowed) refuse('citation-unfetched', `citation ${i + 1}: only research and companion cite a url`);
|
||||
const quote = squash(c.quote);
|
||||
const page = web.pages.find(p => p.url === c.url || p.finalUrl === c.url);
|
||||
if (!page) refuse('citation-unfetched', `citation ${i + 1}: this session fetched no such url through the bus`);
|
||||
if (!quote || !squash(page.text).includes(quote)) refuse('citation-unfetched', `citation ${i + 1}: the quote isn't in the url's kept text`);
|
||||
});
|
||||
const out = [];
|
||||
for (const [i, c] of citations.entries())
|
||||
out.push(found[i] ? { scope: found[i].scope, ...await library.origin(c.commit, c.path) } : { scope: 'web', agent: null });
|
||||
return { citations: out };
|
||||
}
|
||||
|
||||
// Git reads for one check against the bare mirror. `lines` is the file's lines at `commit` when the
|
||||
// commit is reachable from the served commit and readLibrary indexes the path there (sage-3595 item 4:
|
||||
// readLibrary at the cited commit stands in for the C2-05a index), else null. Reads are cached per
|
||||
// commit for the one check.
|
||||
export function libraryReader(mirror, served, mapping) {
|
||||
const indexed = new Map(), reach = new Map();
|
||||
const once = (cache, key, read) => {
|
||||
if (!cache.has(key)) cache.set(key, read().catch(() => null));
|
||||
return cache.get(key);
|
||||
};
|
||||
const reachable = commit => once(reach, commit, async () => {
|
||||
if (!mirror || !served) return false;
|
||||
if (await git(mirror, ['cat-file', '-t', commit]) !== 'commit') return false;
|
||||
await git(mirror, ['merge-base', '--is-ancestor', commit, served]);
|
||||
return true;
|
||||
});
|
||||
const index = commit => once(indexed, commit, async () => {
|
||||
const [read, entries] = await Promise.all([readLibrary({ repo: mirror, commit, mapping }), listTree(mirror, commit)]);
|
||||
return { files: new Set(read.files.map(file => file.path)), entries };
|
||||
});
|
||||
return {
|
||||
async lines(commit, path) {
|
||||
if (!await reachable(commit)) return null;
|
||||
const at = await index(commit);
|
||||
if (!at?.files.has(path)) return null;
|
||||
const texts = await readTexts(mirror, [at.entries.find(entry => entry.path === path)]).catch(() => new Map());
|
||||
return texts.has(path) ? splitLines(texts.get(path)) : null;
|
||||
},
|
||||
// One git call: the file's last commit at `commit` (morgana-0200: the Console compares it with the
|
||||
// served tip's, as the cited library commit needn't touch the file) and that commit's trailer.
|
||||
async origin(commit, path) {
|
||||
if (!mirror) return { agent: null, file: null };
|
||||
try {
|
||||
const [file, trailer = ''] = (await git(mirror, ['log', '-1', '--format=%H%x00%(trailers:key=Mosaic-Agent,valueonly)', commit, '--', path])).split('\0');
|
||||
return { agent: trailer.trim() || null, file: SHA.test(file) ? file : null };
|
||||
} catch { return { agent: null, file: null }; }
|
||||
},
|
||||
// The file's last commit at the served tip, for thread.messages (sage-3595), or null.
|
||||
async last(path) {
|
||||
if (!mirror || !served) return null;
|
||||
try {
|
||||
const value = await git(mirror, ['log', '-1', '--format=%H', served, '--', path]);
|
||||
return SHA.test(value) ? value : null;
|
||||
} catch { return null; }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// thread.messages' `last` (sage-3595): each path's last commit at the served tip, one git call per
|
||||
// distinct path. A path maps to null when there is no served commit or the read fails.
|
||||
export async function lastCommits(context, paths) {
|
||||
const { mirror, served } = await servedMirror(context);
|
||||
const reader = libraryReader(mirror, served, null);
|
||||
const out = new Map();
|
||||
for (const path of new Set(paths)) out.set(path, await reader.last(path));
|
||||
return out;
|
||||
}
|
||||
|
||||
// The library project's bare mirror and its served commit. Either is null when there is none yet, so
|
||||
// a plain reply still passes and any library cite refuses citation-invalid.
|
||||
async function servedMirror(context) {
|
||||
const mapping = context.business.projects[context.project];
|
||||
let mirror = null, served = null;
|
||||
try {
|
||||
mirror = mirrorPath(context);
|
||||
served = (await readSyncState(mirror, { branch: mapping.branch })).served ?? null;
|
||||
} catch { served = null; }
|
||||
return { mapping, mirror, served };
|
||||
}
|
||||
|
||||
// The broker's entry: the library project's mirror at its served commit, then checkReply. `context`
|
||||
// is { business, project, dataRoot }. `definition` is the sender's role definition, which decides only
|
||||
// whether a url cite may pass; `pages` is the broker's page store for the sender's session.
|
||||
export async function guardReply(context, body, { limits, definition, pages }) {
|
||||
const { mapping, mirror, served } = await servedMirror(context);
|
||||
const web = { allowed: WEB_CITE_DEFINITIONS.includes(definition), pages };
|
||||
return checkReply(body, { limits, mapping, library: libraryReader(mirror, served, mapping), web });
|
||||
}
|
||||
@@ -8,3 +8,5 @@ export { renderItems, KINDS, NOTICES, UNREADABLE } from './render.mjs';
|
||||
export { survey, formatSurvey, surveyMain } from './survey.mjs';
|
||||
export { SYNC_STATE_FILE, mirrorPath, ensureMirror, withLibraryLock, servedCommit, readSyncState, libraryStatus, readLibraryFile } from './mirror.mjs';
|
||||
export { syncCommand, sync, ASKPASS, SLOT, SYNC_INTERVAL_MS } from './sync.mjs';
|
||||
export { REPLY_LIMITS, resolveReplyLimits, checkReplyLimits } from './limits.mjs';
|
||||
export { GUARD_CODES, FIXED_TEXTS, WEB_CITE_DEFINITIONS, REASON_MAX, GuardError, parseReply, checkReply, libraryReader, guardReply, lastCommits } from './guard.mjs';
|
||||
@@ -0,0 +1,41 @@
|
||||
// C2-06 (design 5.2): the business file's replyLimits, read by the citation guard and written only
|
||||
// by the human's library.limits.set. Pure, so the business package's organization writer imports it.
|
||||
|
||||
export const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
// The replyLimits keys, their defaults and their valid values. A key is read only when Object.hasOwn
|
||||
// finds it and its value passes `valid`; anything else is the default (webby-0144 R3).
|
||||
export const REPLY_LIMITS = Object.freeze({
|
||||
requireCitation: { default: false, valid: v => typeof v === 'boolean' },
|
||||
citationsPerReply: { default: 20, valid: v => Number.isInteger(v) && v >= 1 && v <= 1000 },
|
||||
});
|
||||
|
||||
// `read` is { readable: false } when the business file is missing, unreadable, not JSON or not an
|
||||
// object at check time, else { readable: true, document }. An unreadable file checks at the defaults
|
||||
// except requireCitation, which is true so a setting she turned on doesn't fail open (webby-0151 R2).
|
||||
export function resolveReplyLimits(read) {
|
||||
const limits = {}, fallback = [];
|
||||
const unreadable = !read?.readable || !plain(read.document);
|
||||
const raw = unreadable ? undefined : read.document.replyLimits;
|
||||
for (const [key, { default: value, valid }] of Object.entries(REPLY_LIMITS)) {
|
||||
if (plain(raw) && Object.hasOwn(raw, key) && valid(raw[key])) limits[key] = raw[key];
|
||||
else {
|
||||
limits[key] = value;
|
||||
// A key fell back when replyLimits is there but isn't an object, or holds the key with a value
|
||||
// outside the table. A key left out is simply its default.
|
||||
if (raw !== undefined && (!plain(raw) || Object.hasOwn(raw, key))) fallback.push(key);
|
||||
}
|
||||
}
|
||||
if (unreadable) limits.requireCitation = true;
|
||||
return { ...limits, fallback, unreadable };
|
||||
}
|
||||
|
||||
// library.limits.set's check: the whole object is replaced, a key left out means its default, and an
|
||||
// unknown key or a value outside the table refuses. Returns the object to write.
|
||||
export function checkReplyLimits(value) {
|
||||
if (!plain(value)) return null;
|
||||
for (const [key, v] of Object.entries(value))
|
||||
if (!Object.hasOwn(REPLY_LIMITS, key) || !REPLY_LIMITS[key].valid(v)) return null;
|
||||
return structuredClone(value);
|
||||
}
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
// C2-14 (#1728, row 227): the Case 2 end-to-end steps on a scratch root, driven by
|
||||
// scripts/test-case2.sh. Usage: node scripts/case2/e2e.mjs <scratch dir>
|
||||
//
|
||||
// One process holds her remote (row 199's fake Gitea on 127.0.0.1 over TLS with a scratch CA), a bus
|
||||
// on a scratch data root and the steps, because the remote's port and CA live only as long as it does.
|
||||
// It prints one line per step:
|
||||
// ok <step> <text>
|
||||
// not ok <step> <text>: <detail>
|
||||
// skip <step> waits on row <ids>: <text>
|
||||
// and exits 0 when it got to the end, 1 when a step threw outside its own check.
|
||||
//
|
||||
// Two stand-ins, both honest about what they are:
|
||||
// - The human. human-cli.mjs proves its caller is a human shell and refuses any process with an agent
|
||||
// among its ancestors (packages/bus/src/human.mjs), so a seat or a reviewer running this script can't
|
||||
// use it. The driver binds the human in-process with bindHuman, as the bus tests do.
|
||||
// - The fake harness. No launcher and no model: the driver binds the creative instance's run on her
|
||||
// thread, claims it, receives her message and answers it.
|
||||
//
|
||||
// Nothing here reads a real key or reaches a real host. The token is made up and lives only in the slot.
|
||||
import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { scratchCa, fakeGitea } from '../../packages/seat/tests/fake-gitea.mjs';
|
||||
import { BASE, MARKER, git, mappingEntry } from '../../packages/library/tests/helpers.mjs';
|
||||
import { startBroker } from '../../packages/bus/src/runtime.mjs';
|
||||
import * as library from '../../packages/library/src/index.mjs';
|
||||
|
||||
const TOKEN = 'fixture-token-c2-14-made-up-0227';
|
||||
const USER = 'books-bot';
|
||||
const BUSINESS = 'acme';
|
||||
const companion = JSON.parse(readFileSync(fileURLToPath(new URL('../../roles/companion.json', import.meta.url)), 'utf8'));
|
||||
|
||||
const lines = [];
|
||||
const out = (line) => { lines.push(line); process.stdout.write(line + '\n'); };
|
||||
// step(id, text, fn): fn returns nothing on a pass and throws on a fail.
|
||||
async function step(id, text, fn) {
|
||||
try { await fn(); out(`ok ${id} ${text}`); }
|
||||
catch (e) { out(`not ok ${id} ${text}: ${String(e?.code ?? '')} ${String(e?.message ?? e).split('\n')[0].slice(0, 300)}`.trimEnd()); }
|
||||
}
|
||||
const skip = (id, rows, text) => out(`skip ${id} waits on row ${rows.join(',')}: ${text}`);
|
||||
const expect = (cond, message) => { if (!cond) throw new Error(message); };
|
||||
const refuses = async (promise, code) => {
|
||||
try { await promise; } catch (e) { if (e?.code === code) return; throw new Error(`refused ${e?.code ?? e?.message}, expected ${code}`); }
|
||||
throw new Error(`answered, expected ${code}`);
|
||||
};
|
||||
|
||||
// Every file under `dir` that holds the token, as bytes.
|
||||
function holdsToken(dir) {
|
||||
for (const name of readdirSync(dir, { recursive: true })) {
|
||||
const file = join(dir, name);
|
||||
let st;
|
||||
try { st = statSync(file); } catch { continue; }
|
||||
if (st.isFile() && readFileSync(file).includes(TOKEN)) return file;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function main(root) {
|
||||
// Her side: a work repo in her layout's shape, cloned bare into the fake Gitea.
|
||||
const work = join(root, 'her', 'books');
|
||||
mkdirSync(work, { recursive: true });
|
||||
git(work, 'init', '-q', '-b', 'main');
|
||||
for (const [path, text] of Object.entries(BASE)) {
|
||||
mkdirSync(dirname(join(work, path)), { recursive: true });
|
||||
writeFileSync(join(work, path), text);
|
||||
}
|
||||
git(work, 'add', '-A');
|
||||
git(work, 'commit', '-qm', 'her first commit');
|
||||
const first = git(work, 'rev-parse', 'HEAD');
|
||||
const tls = scratchCa(join(root, 'ca'));
|
||||
const gitea = await fakeGitea({ dir: join(root, 'gitea'), tls, user: USER, token: TOKEN });
|
||||
const remote = gitea.repo('writer', 'books');
|
||||
mkdirSync(dirname(remote), { recursive: true });
|
||||
git(root, 'clone', '-q', '--bare', '--no-local', work, remote);
|
||||
// Her next commit, pushed to the bare repo; `from` rewinds first, which makes a force-push.
|
||||
const push = (files, { from = null } = {}) => {
|
||||
if (from) git(work, 'reset', '-q', '--hard', from);
|
||||
for (const [path, text] of Object.entries(files)) {
|
||||
mkdirSync(dirname(join(work, path)), { recursive: true });
|
||||
writeFileSync(join(work, path), text);
|
||||
}
|
||||
git(work, 'add', '-A');
|
||||
git(work, 'commit', '-qm', 'her next commit');
|
||||
git(work, 'push', '-q', '--force', remote, 'HEAD:refs/heads/main');
|
||||
return git(work, 'rev-parse', 'HEAD');
|
||||
};
|
||||
const remoteHead = () => git(remote, 'rev-parse', 'refs/heads/main');
|
||||
|
||||
// Our side: a scratch data root and config dir with the slot, and a bus with one creative instance.
|
||||
const dataRoot = join(root, 'data'), configDir = join(root, 'config');
|
||||
mkdirSync(dataRoot, { mode: 0o700 });
|
||||
mkdirSync(join(configDir, 'secrets', BUSINESS), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(join(configDir, 'secrets', BUSINESS, library.SLOT), TOKEN + '\n', { mode: 0o600 });
|
||||
const entry = { ...mappingEntry(), remote: `${gitea.origin}/writer/books.git`, user: USER };
|
||||
const business = {
|
||||
id: BUSINESS, human: 'jason', arbiters: { technical: 'bean', delivery: 'bean' },
|
||||
roles: { bean: { definition: 'companion', authority: companion.authority } },
|
||||
definitions: { companion: { messaging: companion.messaging } },
|
||||
projects: { books: entry },
|
||||
};
|
||||
// syncCommand's argv with the scratch CA added, as packages/library/tests/sync.test.mjs does. The
|
||||
// timer's period is long, so only its pass at start and the steps' own syncs fetch.
|
||||
const command = (kind, url, opts) => {
|
||||
const c = library.syncCommand(kind, url, opts);
|
||||
return { argv: c.argv, env: { ...c.env, GIT_SSL_CAINFO: tls.ca } };
|
||||
};
|
||||
const bus = await startBroker({ dataRoot, businesses: { [BUSINESS]: business }, librarySync: { command, intervalMs: 3_600_000, configDir } });
|
||||
try {
|
||||
const broker = bus.broker;
|
||||
const human = broker.bindHuman({ business: BUSINESS, human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const call = (cap, verb, args = {}) => Promise.resolve().then(() => broker.request(cap, { verb, args }));
|
||||
// The timer's pass at start may still hold the mirror; a step's sync waits it out.
|
||||
const sync = async (args = {}) => {
|
||||
for (let i = 0; ; i++) {
|
||||
try { return await call(human, 'library.sync', { project: 'books', ...args }); }
|
||||
catch (e) { if (e?.code !== 'library-sync-busy' || i > 100) throw e; await new Promise(r => setTimeout(r, 100)); }
|
||||
}
|
||||
};
|
||||
const refusals = () => readRefusals(dataRoot);
|
||||
|
||||
// S1: her layout and the mapping that reads it.
|
||||
const mapping = library.checkMapping(entry);
|
||||
await step('S1a', 'her layout classifies: a character is canon, a Playground note is playground, a top-level note is unmapped', () => {
|
||||
const of = p => library.classify(mapping, p);
|
||||
expect(of('Book One/Characters/Mara Vell.md').scope === 'canon' && of('Book One/Characters/Mara Vell.md').type === 'character', 'character');
|
||||
expect(of('Book One/Chapters/Chapter 01.md').type === 'manuscript', 'chapter');
|
||||
expect(of('Book One/Playground/What If.md').scope === 'playground', 'playground');
|
||||
expect(of('Inbox Note.md').scope === 'unmapped', 'inbox note');
|
||||
expect(of('Second World/Chapters/01.md').universe === 'second', 'second universe');
|
||||
});
|
||||
skip('S1b', [283], 'a supporting folder in the mapping and the editor field');
|
||||
|
||||
// S2: sync from her bare repo over https, and read at the served commit.
|
||||
await step('S2a', 'library.sync fetches her bare repo and serves its head', async () => {
|
||||
const r = await sync();
|
||||
expect(r.served === remoteHead() && r.served === first, `served ${r.served}, remote ${remoteHead()}`);
|
||||
expect(r.lastError === null && r.diverged === null, 'state has an error or a divergence');
|
||||
});
|
||||
await step('S2b', 'library.file reads her canon and playground notes at the served commit, and refuses an unmapped one', async () => {
|
||||
const canon = await call(human, 'library.file', { project: 'books', path: 'Book One/Characters/Mara Vell.md' });
|
||||
expect(canon.commit === first && canon.content.includes(MARKER), 'canon note');
|
||||
const play = await call(human, 'library.file', { project: 'books', path: 'Book One/Playground/What If.md' });
|
||||
expect(play.content.includes(MARKER), 'playground note');
|
||||
await refuses(call(human, 'library.file', { project: 'books', path: 'Inbox Note.md' }), 'project-read-path');
|
||||
});
|
||||
await step('S2c', 'her next commit is a fast-forward and the next sync serves it', async () => {
|
||||
const next = push({ 'Book One/Places/Harbor Town.md': `---\ntype: place\n---\nSalt and tar. ${MARKER}\n` });
|
||||
const r = await sync();
|
||||
expect(r.served === next, `served ${r.served}, expected ${next}`);
|
||||
});
|
||||
await step('S2d', 'the token is in the slot only: nowhere under the data root, the mirror included', () => {
|
||||
const file = holdsToken(dataRoot);
|
||||
expect(file === null, `token found under the data root (${file === null ? '' : file.slice(dataRoot.length)})`);
|
||||
});
|
||||
|
||||
// S3: she asks the creative instance in its thread; the fake harness answers.
|
||||
let thread, asked;
|
||||
await step('S3a', 'she makes a thread with the creative instance and asks in it', async () => {
|
||||
const project = (await call(human, 'project.create', { name: 'books' })).id;
|
||||
const made = await call(human, 'thread.create', { project, name: 'Book One', instance: 'bean' });
|
||||
expect(made.instance === 'bean', `instance ${made.instance}`);
|
||||
thread = made.id;
|
||||
asked = (await call(human, 'thread.send', { thread, body: 'What colour are Mara Vell\'s eyes?' })).id;
|
||||
});
|
||||
await step('S3b', 'the fake harness receives her message on its thread and its reply is stored in her thread', async () => {
|
||||
const run = broker.bindLaunch({ business: BUSINESS, role: 'bean', run: 'bean-c2-14', harness: 'pi', thread });
|
||||
await call(run, 'role.claim');
|
||||
const inbox = await call(run, 'message.receive');
|
||||
expect(inbox.length === 1 && inbox[0].id === asked, `received ${inbox.length}`);
|
||||
await call(run, 'message.send', { to: 'human', body: 'Grey. (Book One/Characters/Mara Vell.md)', in_reply_to: asked });
|
||||
const history = await call(human, 'thread.messages', { thread });
|
||||
expect(history.length === 2 && history[1].from_role === 'bean' && history[1].in_reply_to === asked,
|
||||
`history ${history.map(m => m.from_role).join(',')}`);
|
||||
});
|
||||
skip('S3c', [216, 217], 'the reply carries cites from the lookups, the guard verifies each one at the served commit, and the store keeps them');
|
||||
skip('S4', [217], 'a reply citing a playground note with "as": "canon" refuses playground-cited-as-canon');
|
||||
|
||||
// S5: a force-push holds as library-diverged until she accepts the new tip.
|
||||
await step('S5a', 'a force-push of her branch holds as library-diverged and served stays put', async () => {
|
||||
const before = (await call(human, 'library.status', { project: 'books' })).served;
|
||||
const forced = push({ 'Book One/Places/Harbor Town.md': `---\ntype: place\n---\nRewritten. ${MARKER}\n` }, { from: first });
|
||||
await refuses(sync(), 'library-diverged');
|
||||
const status = await call(human, 'library.status', { project: 'books' });
|
||||
expect(status.served === before, 'served moved');
|
||||
expect(status.diverged?.commit === forced, `diverged ${JSON.stringify(status.diverged)}`);
|
||||
expect(refusals().includes('library-diverged'), 'no recorded refusal');
|
||||
});
|
||||
await step('S5b', 'accept refuses a stale commit, then serves the new tip and clears the hold', async () => {
|
||||
await refuses(sync({ accept: first }), 'library-accept-stale');
|
||||
const tip = remoteHead();
|
||||
const r = await sync({ accept: tip });
|
||||
expect(r.served === tip && r.diverged === null, `served ${r.served}`);
|
||||
});
|
||||
|
||||
// S6-S9: agent writes, the story-path refusal, her revert and her Console save.
|
||||
skip('S6', [283, 285, 286, 288], 'a seat with a fixture grant writes a supporting doc: one commit with the trailer in the bare repo, and the notice in her thread');
|
||||
skip('S7', [283, 288], 'a write to a story path refuses library-write-story-path, and the bare repo\'s ref is unchanged');
|
||||
skip('S8', [288], 'her revert of the write lands as one commit');
|
||||
skip('S9', [283, 288], 'her library.file.save lands as one commit with her editor as author');
|
||||
// S10: the Console.
|
||||
skip('S10', [220, 225], 'the Console renders the reply and the notice');
|
||||
} finally {
|
||||
await bus.close();
|
||||
await gitea.close();
|
||||
}
|
||||
}
|
||||
|
||||
// The codes of the bus's recorded refusals, oldest first, read from its store on a read-only handle.
|
||||
function readRefusals(dataRoot) {
|
||||
const db = new DatabaseSync(join(dataRoot, 'bus', 'bus.sqlite'), { readOnly: true });
|
||||
try { return db.prepare("SELECT body FROM events WHERE kind='action.refused' ORDER BY seq").all().map(row => JSON.parse(row.body).code); }
|
||||
finally { db.close(); }
|
||||
}
|
||||
|
||||
const root = process.argv[2];
|
||||
if (!root) { process.stderr.write('usage: node scripts/case2/e2e.mjs <scratch dir>\n'); process.exit(2); }
|
||||
try { await main(resolve(root)); }
|
||||
catch (e) { process.stderr.write(`e2e: stopped: ${e?.code ?? ''} ${String(e?.message ?? e).split('\n')[0]}\n`); process.exitCode = 1; }
|
||||
@@ -1,94 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Case 2 end-to-end acceptance on a scratch root (C2-14, #1728, row 227).
|
||||
#
|
||||
# Her layout in a bare repo behind a fake Gitea on 127.0.0.1 (TLS, scratch CA),
|
||||
# a bus on a scratch data root with one creative instance, and the steps of the
|
||||
# C2-14 brief in order. scripts/case2/e2e.mjs runs the steps in one process,
|
||||
# since the remote lives only as long as that process does, and prints one line
|
||||
# per step. This script checks its syntax, runs it, and counts the lines.
|
||||
#
|
||||
# No live host, no real model, no network past 127.0.0.1, no real key. The
|
||||
# human is an in-process stand-in: human-cli.mjs refuses a caller with an agent
|
||||
# among its ancestors, so a seat can't drive it. The creative instance's run is
|
||||
# a fake harness bound by the driver, with no launcher.
|
||||
#
|
||||
# A step that waits on a row not yet on refactor prints a skip line naming the
|
||||
# row, and counts as neither pass nor fail. When the row lands, its step gets
|
||||
# written in the driver and its skip line goes.
|
||||
#
|
||||
# scripts/test-case2.sh full run
|
||||
# NO_COLOR=1 scripts/test-case2.sh plain output
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
SANDBOX="$(mktemp -d)"
|
||||
trap 'rm -rf "$SANDBOX"' EXIT
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
SKIP=0
|
||||
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
|
||||
C_OK=$'\033[0;32m'; C_FAIL=$'\033[0;31m'; C_RESET=$'\033[0m'
|
||||
else
|
||||
C_OK=""; C_FAIL=""; C_RESET=""
|
||||
fi
|
||||
|
||||
check() {
|
||||
if [ "$2" = "0" ]; then PASS=$((PASS+1)); echo "${C_OK}OK${C_RESET} $1"; else FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} $1"; fi
|
||||
}
|
||||
|
||||
DRIVER="scripts/case2/e2e.mjs"
|
||||
# The steps the driver must print, in order. A missing or extra line fails.
|
||||
STEPS="S1a S1b S2a S2b S2c S2d S3a S3b S3c S4 S5a S5b S6 S7 S8 S9 S10"
|
||||
|
||||
echo "toolchain: node $(node --version), git $(git --version | cut -d' ' -f3), openssl $(openssl version | cut -d' ' -f2)"
|
||||
echo
|
||||
|
||||
# --- syntax ---
|
||||
node --check "$DRIVER" >/dev/null 2>&1
|
||||
check "syntax: $DRIVER" $?
|
||||
|
||||
# --- the steps ---
|
||||
# The driver's own state stays in the sandbox. Git reads no global or system
|
||||
# config, so a caller's hooks, signing or credential helpers can't reach it.
|
||||
OUT="$SANDBOX/steps.txt"
|
||||
ERR="$SANDBOX/stderr.txt"
|
||||
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 \
|
||||
node "$DRIVER" "$SANDBOX/root" >"$OUT" 2>"$ERR"
|
||||
RC=$?
|
||||
check "driver ran to the end (exit $RC)" "$RC"
|
||||
if [ "$RC" -ne 0 ]; then
|
||||
# The driver prints codes and first lines only; never git's stderr with a token in it.
|
||||
grep '^e2e: ' "$ERR" | head -5
|
||||
fi
|
||||
|
||||
seen=""
|
||||
while IFS= read -r line; do
|
||||
case "$line" in
|
||||
"ok "*)
|
||||
id="${line#ok }"; id="${id%% *}"
|
||||
check "${line#ok }" 0 ;;
|
||||
"not ok "*)
|
||||
id="${line#not ok }"; id="${id%% *}"
|
||||
check "${line#not ok }" 1 ;;
|
||||
"skip "*)
|
||||
id="${line#skip }"; id="${id%% *}"
|
||||
SKIP=$((SKIP+1)); echo "skip ${line#skip }" ;;
|
||||
*)
|
||||
id=""
|
||||
check "driver line not understood: ${line:0:200}" 1 ;;
|
||||
esac
|
||||
[ -n "$id" ] && seen="$seen $id"
|
||||
done <"$OUT"
|
||||
[ "${seen# }" = "$STEPS" ]
|
||||
check "the driver printed each step once, in order" $?
|
||||
|
||||
# --- the token ---
|
||||
# The made-up token is in the slot and nowhere else the bus writes. The driver
|
||||
# checks the data root; this checks its output too.
|
||||
! grep -rqF 'fixture-token-c2-14-made-up-0227' "$OUT" "$ERR"
|
||||
check "the fixture token is in no driver output" $?
|
||||
|
||||
echo
|
||||
echo "selftest: $PASS passed, $FAIL failed, $SKIP skipped (each skip names the row it waits on)"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
Reference in new issue
Block a user