Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23816cf285 | ||
|
|
38e322ea01 | ||
|
|
4f3fbc25a0 |
No files matched your search
@@ -0,0 +1,170 @@
|
||||
# Row 224 — C2-05b broker lookups and typed tools (#1725)
|
||||
|
||||
Owner: Della; reviewer: Dewey. Assignment sage-2795; scope extension sage-2804.
|
||||
Base: `4f3fbc25a` (Researcher C2-05a). Branch: `della/row224`.
|
||||
Brief: C2-05b in `docs/plans/2026-10-20_case2-rows.md` at `aca16404c`
|
||||
and `51cc0445c`; design section 3, 5.3 M3 and 7.2; C2-05a BUILD rulings.
|
||||
|
||||
## Execution boundary and progress
|
||||
|
||||
Code/docs and local Git only while row 253 holds the gate. No Node (including
|
||||
syntax checks), HTTPS Git, queue claim, test or push until Sage's CLEAR.
|
||||
Tests are written first; their RED/GREEN runs are deferred, not claimed.
|
||||
Queue next is deferred too: its executable uses Node. Sage assigned this row
|
||||
explicitly. After 253 and 216 land, rebase under a new branch name.
|
||||
|
||||
- Pre-flight: query.mjs consumes `{repo,commit,mapping}`; broker cannot import
|
||||
Git/filesystem library code because it is in the sandbox import closure.
|
||||
Runtime injects the host lookup adapter; existing human file/status/sync stay.
|
||||
- Pre-flight: resolved has no project list and a library is never a checkout.
|
||||
Bundle needs an explicit library flag from launcher business configuration.
|
||||
- M3 reading: exact creative base is five lookups plus send_message; retain
|
||||
explicitly granted broker web_search/web_fetch for research/companion under
|
||||
landed C2-08 and sage-2804. No generic spec, task, evidence or decision tools. Residual (mos-1308): those
|
||||
reads lack broker action checks; their typed-tool absence is the only fence
|
||||
for creative roles. M1/M2 deny bash; broker-side refusal is outside this row.
|
||||
Mos confirmed this interpretation in mos-1308; design wording fix belongs to Gosalyn. Other definitions retain their current tools.
|
||||
- Multi-library businesses use an optional project ID; omit it only with one
|
||||
configured library. Universe is required for all five tools, including get.
|
||||
- Missing library uses the brief's `library-not-configured`; argument errors
|
||||
reuse `invalid-request` and C2-05a query codes. No new action or authority.
|
||||
|
||||
## Manifest (draft code, unrun)
|
||||
|
||||
- bus/src/broker.mjs: five lookup verbs, strict args, holder/business rechecks.
|
||||
- bus/src/runtime.mjs: host lookup adapter, stale-before-read sync, served pin.
|
||||
- harness/src/tools.mjs: five schemas; creative exact-set filtering.
|
||||
- harness/src/bundle.mjs: library flag forwarded to typedTools.
|
||||
- cli/src/launcher.mjs: one flag derived from trusted business projects.
|
||||
- bus/tests/library-lookups.test.mjs: real query/broker/socket/runtime cases.
|
||||
- harness/tests/library-tools.test.mjs: schemas, role snapshots, bundle parity.
|
||||
- cli/tests/thread-launch.test.mjs: flag reaches a real launched bundle with no library checkout.
|
||||
- agents/della/work/queue-224/BUILD.md: manifest, rulings and deferred verification ledger.
|
||||
|
||||
## Verification ledger
|
||||
|
||||
No test or syntax-check run yet. Acceptance requires full bus/cli/harness suites
|
||||
and every scripts/test-*.sh on Sage's gate; focused files and affected CLI/
|
||||
seat import-closure tests precede that only in an authorized CLEAR.
|
||||
|
||||
## Coordination
|
||||
|
||||
Huey272 reports no runtime/bundle/launcher overlap. Rocko219 changes launcher
|
||||
THREAD_DEFINITIONS checks only, separate from our buildBundle flag argument.
|
||||
The launcher argument is required: bundle cannot derive library presence from
|
||||
checkout IDs (a library has no agent clone). No role, authority or config schema
|
||||
changes; library project entries are already validated and protected by M9.
|
||||
|
||||
## Mount and pin impact (inspection only)
|
||||
|
||||
No new imports in broker.mjs or tools.mjs; no RUNNER_FILES or sandbox mount
|
||||
addition. runtime.mjs is host-only and already imports library index.mjs.
|
||||
Bundle forwards only a boolean, never a root/mapping/slot/credential. The
|
||||
existing import-closure tests are bus/tests/project-runtime.test.mjs and
|
||||
seat/tests/sandbox.test.mjs. Grep found no literal sha256 pin for runtime.mjs
|
||||
or bundle.mjs; bundle tests derive current source digests through SOURCES.
|
||||
Library sources remain in the existing whole-directory release copy.
|
||||
|
||||
## Runs to schedule only after CLEAR
|
||||
|
||||
Set TMPDIR=/mnt/storage/scratch/tmp. Focused new bus/harness files first,
|
||||
then existing library.test.mjs, library-sync.test.mjs, project-runtime.test.mjs,
|
||||
harness creative/tools/spec-tools/bundle files and affected CLI launch tests.
|
||||
Follow with the required whole bus/cli/harness suites and Sage's full shell gate.
|
||||
No timing claim yet: use the repository/Sage per-file limits and record every
|
||||
command, outcome, elapsed time and timeout before acceptance. No focused skip
|
||||
or pattern run substitutes for whole-file gate evidence.
|
||||
|
||||
## Read-through findings and residuals
|
||||
|
||||
- Library get requires universe although the older design table omitted it;
|
||||
section 3 isolation rule and C2-05a's accepted API are binding.
|
||||
- Query type enum matches mapping.TYPES (five values); manuscript is a distinct
|
||||
lookup and not a find type. Unknown host-path/commit/business/accept fields
|
||||
are refused; explicit project is restricted to this business's library list.
|
||||
- Async reads recheck the live holder and current project revision. A stale
|
||||
read attempts sync, then takes the new served snapshot; a returned fetch
|
||||
failure/divergence keeps serving the old pin. Busy sync uses the current pin.
|
||||
Unreadable state, no served commit or unsafe configuration refuse. Query
|
||||
and sync never hold a SQLite transaction over await.
|
||||
- Processing halt refuses new agent lookup work and rechecks before returning
|
||||
async results. Existing human library file/status/sync behavior is unchanged.
|
||||
- Timer sync and reads still use C2-03a's 60-second contract. No timer tuning or
|
||||
resource-limit schema is introduced by this row. Q222 creative reply settings
|
||||
are in C2-06; no reply guard or writing capability is implemented here.
|
||||
- The generic tool socket's existing 60-second client timeout can precede the
|
||||
server's 120-second Git timeout. This row does not change the generic timeout;
|
||||
a timed-out lookup is read-only and retryable, never a write/outcome claim.
|
||||
- All tests are authored but unrun. No RED, GREEN, syntax, runtime performance,
|
||||
sandbox, broker/harness suite, shell gate, claim or push evidence exists yet.
|
||||
|
||||
## Candidate review checklist (read-through only)
|
||||
|
||||
- [x] Five verbs mapped to real C2-05a methods, all require universe.
|
||||
- [x] Default sole library or explicit owned project; no caller repo/commit.
|
||||
- [x] Human-only file/status/sync gates kept; lookup holder rechecked.
|
||||
- [x] Async socket dispatch includes lookup verbs through LIBRARY_VERBS.
|
||||
- [x] Stale/unknown fetch syncs first; results use current immutable served pin.
|
||||
- [x] Six/eight creative names per mos-1308; other roles retain their tools.
|
||||
- [x] Launcher -> bundle -> typedTools passes presence without a library clone.
|
||||
- [x] No imports added to sandbox source closure; no mount change.
|
||||
- [ ] Execute new tests, RED characterization, full files/suites after CLEAR.
|
||||
- [ ] Rebase after rows 253/216 land, under a new branch name.
|
||||
- [ ] Claim/push only in Sage-authorized gap; Dewey review and Sage gate.
|
||||
|
||||
Candidate preservation commits are explicitly unrun under sage-2795; they are
|
||||
not completion, green-suite or landing claims. No dependencies installed.
|
||||
|
||||
Correction: the first committed-range diff --check found a blank line at EOF
|
||||
in the new library-tools test, missed while it was untracked. Removed in a
|
||||
follow-up local commit; the complete range must be checked again. No tests ran.
|
||||
|
||||
## Shared-log receipts for Sage (sage-2815)
|
||||
|
||||
The shared logs are unchanged from the base in this row. Sage appends these
|
||||
receipts in the landing sweep; they are preserved here, not in the manifest.
|
||||
|
||||
For `BUILD-LOG.md`:
|
||||
|
||||
```text
|
||||
2026-10-22T16:36:30Z | Della row224 C2-05b #1725 preparation | Before: C2-05a queries exist at 4f3fbc25a but no broker lookup verbs or typed tools. Scope: broker/tools plus Sage-authorized runtime/bundle integration, tests and docs. Existing human-only library reads retained; five agent queries require a live holder and trusted configured source. Code/docs only under row253 gate; no Node/test/push/claim. Runtime ownership coordinated with Rocko219/Huey272.
|
||||
```
|
||||
|
||||
For `docs/SESSIONS.md`:
|
||||
|
||||
```text
|
||||
2026-10-22T16:36:30Z | Della (sage-2795 / sage-2804, Q220) | Row 224 C2-05b #1725, della/row224 base 4f3fbc25a | Read both pinned briefs and C2-05a source; preparing broker/typed tools and authorized host runtime/bundle seams. No queue command, Node, HTTPS Git, claim, test or push during Gander gate. Dewey reviewer; Rocko/Huey runtime coordination sent. Tests and integration verification deferred to CLEAR.
|
||||
```
|
||||
|
||||
Local-only draft pins 19c0a7adf/7cf39c390 are superseded by the
|
||||
log-free candidate. Saved as local branch della/row224-draft-with-logs before
|
||||
rebuilding the unpublished candidate commit; no remote history changed.
|
||||
|
||||
## Dewey dewey-0852 and Sage sage-2819 — focused corrections
|
||||
|
||||
B1: inline freshness uses max(lastFetch, lastError.at), not lastFetch alone.
|
||||
An error within 60 seconds counts as a recent attempt, so later lookups serve
|
||||
the old served pin without another fetch. Keep inline sync: the brief requires
|
||||
it and businesses without timed libraries have no sync timer. The extended
|
||||
failed-fetch test makes reads at +1, +59,999 and +60,000 ms (one attempt total),
|
||||
then +60,001 ms (second attempt). All reads must return the old commit.
|
||||
|
||||
Required mutant M-B1: remove lastError.at from recentAttempt. The repeated
|
||||
reads must fail the attempted-count assertion. This mutant has NOT been run.
|
||||
|
||||
Residual (Sage will defer): the first inline fetch in each outage window can
|
||||
still exceed the tool timeout (60 seconds, or the runner broker client’s
|
||||
30 seconds). Bounding that fetch is out of scope for 224. Subsequent reads
|
||||
are paced by persisted lastError.at. No claim of first-read outage latency.
|
||||
|
||||
N1: whole bus, cli and harness suites are required, plus the shell gate.
|
||||
If the new thread-launch integration test skips due to sandboxProblem(),
|
||||
name the skip; a skip is not launcher integration proof.
|
||||
N2: cli/tests/thread-launch.test.mjs now depends on library/tests/helpers.mjs
|
||||
mappingEntry() and library/tests/fixtures/mapping.json. Changes to that fixture
|
||||
must rerun the CLI launch file.
|
||||
N3: harness/tests/library-tools.test.mjs compares the type enum to mapping.mjs
|
||||
TYPES, so adding a mapped type cannot silently leave the typed tool behind.
|
||||
|
||||
These edits are local code/docs only. No Node, RED/GREEN run, mutant, claim,
|
||||
HTTPS Git or push. New local pin goes to Dewey/Mos/Sage for focused review.
|
||||
@@ -0,0 +1,184 @@
|
||||
# Row 216 build notes (C2-05a, lookup index and queries, #1717)
|
||||
|
||||
Phase A only: code, tests and this file. No test, suite or mutant has been run. `node --check` passed on every changed `.mjs`.
|
||||
|
||||
- Branch: `researcher/row216`
|
||||
- Base: `d372102882d6858fa77af4aee453f866c886ad5c` (origin/refactor)
|
||||
- Design: `agents/gosalyn/work/case2/DESIGN.md` on origin/gosalyn/case2-design, section 3 (table at lines 625-631, bullets 633-641), 1.2-1.4, B12, D2
|
||||
- Brief: `docs/plans/2026-10-20_case2-rows.md`, C2-05a at lines 320-357
|
||||
|
||||
## Files
|
||||
|
||||
| Path | Status |
|
||||
|---|---|
|
||||
| `packages/library/src/index-build.mjs` | new |
|
||||
| `packages/library/src/query.mjs` | new |
|
||||
| `packages/library/src/index.mjs` | edit: 2 export lines added |
|
||||
| `packages/library/tests/query-fixture.mjs` | new, test helper |
|
||||
| `packages/library/tests/query.test.mjs` | new |
|
||||
| `packages/library/tests/index-build.test.mjs` | new |
|
||||
| `agents/researcher/work/queue-216/BUILD.md` | new (this file) |
|
||||
|
||||
No existing test or source file changed beyond `index.mjs`.
|
||||
|
||||
## Design rows and where each is tested
|
||||
|
||||
| Design row | Code | Tests (query.test.mjs unless noted) |
|
||||
|---|---|---|
|
||||
| `library_find` | `find` | find is bound to its universe; every result carries its scope; long list pages; large items page under the cap |
|
||||
| `library_get` | `get` | hybrid gets a source file; get lists visible links both ways; get pages its lines |
|
||||
| `library_search` | `search` | search returns scoped line hits; item that cannot fit alone is refused |
|
||||
| `library_manuscript` | `manuscript` | one chapter at most 400 lines; refused without chapter; hybrid reads a source chapter |
|
||||
| `library_universe` | `universeInfo` | universe info reports a hybrid's sources, constraints, notes; unknown universe refused |
|
||||
| Hybrid semantics (1.4) | `sees` | a hybrid sees its sources canon minus exclusions, no playground |
|
||||
| Unmapped never appears | index build + `sees` | unmapped files never appear (query.test); index holds mapped files only (index-build.test) |
|
||||
| Per-commit cache (3) | `indexFor` | same commit, path, mapping return one index; different path, mapping or commit get their own; eight-entry bound; failed read not kept; unchecked mapping refused (index-build.test) |
|
||||
| Rebuild time (3, C2-05a gate) | `indexFor` | 3,000-file rebuild, time reported with `t.diagnostic`, no threshold asserted |
|
||||
| REPLY_CAP parity | `QUERY_REPLY_CAP` | equals `REPLY_CAP` from harness tools.mjs |
|
||||
|
||||
## Choices where the design table is silent or differs
|
||||
|
||||
These are departures or gaps. Each is built as stated, so Sage can rule before Phase B. A ruling that changes one of them is a small edit.
|
||||
|
||||
1. **Paging arguments (gap a).** The table has no offset. `find` and `search` take an optional `offset` (a position in the filtered list, default 0). A reply carries `next`: the position of the next item, or `null`.
|
||||
2. **`library_get` has no universe (gap b).** Built with a required `universe`. Without it the reply could not be isolated. A missing file and a file the universe cannot see both return `null`, so a caller cannot tell them apart. `get` also takes an optional `from` line.
|
||||
3. **`get` over the cap (gap c).** Lines page by `from` with `next`. `links_out` and `links_in` come back whole on every page. If the frontmatter and links alone exceed the cap, the call is refused with `query-item-too-large`. A hub note with many backlinks may be refused. This needs a ruling: link paging, or accept the refusal.
|
||||
4. **`manuscript` without a chapter (gap d).** Refused with `query-chapter-required`. The design names no list form. A chapter name that matches two visible files is refused with `query-chapter-ambiguous`.
|
||||
5. **`search` excerpt (gap e).** The excerpt is the whole line, not a truncation. A line that alone exceeds the cap is refused. Truncating it would cut an item.
|
||||
6. **`exclude` form (gap f).** `checkMapping` accepts only a folder path for `exclude` (`folder()`), so an exclusion is a path prefix. There is no type or source form to build. Confirm.
|
||||
7. **Pin constraints.** `pin` does not hide any file. It is reported by `universeInfo` only. Confirm.
|
||||
8. **Playground through `get`.** `scope` applies to lists. `get` by an explicit path returns a playground file of the caller's own universe. Confirm.
|
||||
9. **Links.** A link into a file the universe cannot see is dropped from `links_out` and `links_in`, in line with N1. Links from a visible file that point into another universe are kept in the index (the reader keeps them under R6), and the query layer drops them for universes that cannot see them.
|
||||
10. **`find` matching.** Case-insensitive substring over the front matter `name` and the file name. An empty `query` matches every file in scope.
|
||||
11. **Cache key.** `repo`, `commit` and a sha256 of the canonical mapping. Keying by commit alone would return the wrong scopes after a mapping change. The cache holds eight entries, least recently used out. Failed reads are not kept.
|
||||
12. **`universeInfo`.** Reads only the checked mapping and the served commit. It builds no index.
|
||||
13. **Reply commit.** Every reply carries the index commit, which is the served commit passed in.
|
||||
|
||||
## Provisional refusal codes (pending Sage approval)
|
||||
|
||||
The brief says new refusal codes need Sage's approval first. These are provisional and must not be treated as settled:
|
||||
|
||||
- `query-argument-invalid`
|
||||
- `query-universe-unknown`
|
||||
- `query-chapter-required`
|
||||
- `query-chapter-ambiguous`
|
||||
- `query-item-too-large`
|
||||
|
||||
Reused, existing codes: `library-mapping-invalid`, plus the reader's `library-git`, `library-commit` through `indexFor`.
|
||||
|
||||
## Grep results (grep only, not a test run)
|
||||
|
||||
- Test references to the changed paths: `packages/library/tests/*.mjs` import `../src/index.mjs` and `../src/mapping.mjs`. `packages/bus/tests/library.test.mjs` and `library-sync.test.mjs` import the library index as a namespace. Neither enumerates its exports, so the two new export lines break no assertion.
|
||||
- `packages/cli/tests/stack-copy.test.mjs:52` checks that `packages/library/src/index.mjs` exists, and it still does.
|
||||
- 64-hex pins: none in `packages/library/tests`, none on `index.mjs`, `query.mjs` or `index-build.mjs`.
|
||||
- `scripts/test-*.sh`: no reference to the new modules.
|
||||
|
||||
## Flag for Sage
|
||||
|
||||
- `packages/library/tests/helpers.mjs` `repo()` builds its fixtures under `tmpdir()`, which is `/tmp`. The launcher rules forbid that. The new tests do not use it; they use `tests/query-fixture.mjs`, which builds under `/mnt/storage/scratch/tmp`. The existing helper should be fixed in a separate row, not here.
|
||||
|
||||
## Phase B commands (not run)
|
||||
|
||||
Run only after Sage sends "slot clear". Set the scratch TMPDIR first, per the launcher rule.
|
||||
|
||||
```
|
||||
cd /mnt/storage/src/mosaic-stack/.claude/worktrees/agent-aa3cc98787bc5152c/packages/library
|
||||
export TMPDIR=/mnt/storage/scratch/tmp
|
||||
node --test tests/query.test.mjs tests/index-build.test.mjs
|
||||
node --test tests/*.test.mjs
|
||||
```
|
||||
|
||||
Also run `packages/bus/tests/library.test.mjs` and `library-sync.test.mjs`, since they import the index.
|
||||
|
||||
## Status
|
||||
|
||||
Not yet verified. Nothing has been tested and nothing is committed. The working tree holds the Phase A changes. AGENTS.md invariant 8 allows a commit only after the applicable suites are green, so the commit waits for Phase B. The dispatch asked for a local commit in Phase A; Sage to confirm that the commit waits for Phase B.
|
||||
|
||||
## Round 2 (2026-10-22, sage-2708, sage-2716): rulings applied, rebased onto aca16404c
|
||||
|
||||
Added after the text above; nothing above is edited. "Nothing is committed", "Provisional refusal codes",
|
||||
`query-chapter-required` and the tmpdir flag above were true when written and are superseded here.
|
||||
|
||||
- Branch `researcher/row216-r2`, one local commit on origin/refactor `aca16404c`. Row 268 (C2-01c) is in
|
||||
that base. Row 253 has not landed; the work rebases onto 253's tip before any gate. Not pushed, not run.
|
||||
- Rulings: `agents/sage/work/queue-216/RULINGS.md` at da9555ca2, rulings 1-13 and the tmpdir flag.
|
||||
- Drift check, d37210288..aca16404c against my paths: the library files that changed are `mapping.mjs`,
|
||||
`scope.mjs`, `README.md` and the mapping and scope tests. None is a file I add or edit. `query.mjs` reads
|
||||
the checked mapping through them, so the whole library package runs in phase B.
|
||||
|
||||
### What changed from phase A
|
||||
|
||||
| Ruling | Change in `query.mjs` | Test |
|
||||
|---|---|---|
|
||||
| 3 `get` over the cap | `links_out` and `links_in` page together with `links_from`; every reply carries `links_next`, `next` and the front matter. A page with lines left carries at least one line. Links take at most half the cap while lines remain. `query-item-too-large` is left for front matter alone or one line alone (or one link alone) | hub note with 900 backlinks pages and adds up; front matter alone and a line alone refused; bad `links_from` |
|
||||
| 4 `manuscript` no chapter | `chapter` optional: the first visible chapter in path order. Every reply carries `next_chapter`. `query-chapter-required` removed. Ambiguity still refuses | first chapter and `next_chapter`; ambiguous name refused |
|
||||
| 5 `search` long line | hit comes back `{path, line, excerpt: null, excerpt_too_long: true, scope}`, other hits still return | marked hit and unmarked hits in one reply |
|
||||
| 6 `exclude` | path-prefix reading only. The test names `EXCLUSION` and `EXCLUDED_PATHS` in `query-fixture.mjs`, so C2-15 (row 267) swaps two constants | exclusion hides from the hybrid and not from the source |
|
||||
| 8 hybrid `get` | unchanged code; test added | hybrid gets a source's playground file as `null`; the source gets it with `scope: "playground"` |
|
||||
| PRD 1.14 (row 268) | `universeInfo` reports `kind` from the mapping (`draft` included) | four tests below |
|
||||
|
||||
PRD 1.14 tests, one each: a lookup naming a `book` leaves `book: null` files out (find and search);
|
||||
`library_manuscript` never reads a `book: null` file; a draft universe has no canon, so its lookups return
|
||||
files only with `scope: "playground"`; `library_universe` on a draft returns kind, name and prefixes.
|
||||
The levels fixture is `WITH_LEVELS` in `query.test.mjs`: harbor gets `Notes/Harbor/` as a universe-level
|
||||
playground prefix, and `idea` is a draft over `Drafts/New World/`.
|
||||
|
||||
`query-fixture.mjs` builds under `tmpdir()`, as `helpers.mjs` does (ruling "tmpdir flag"). The earlier
|
||||
flag about `helpers.mjs` building under `/tmp` is closed by that ruling: the gate sets `TMPDIR`.
|
||||
|
||||
Refusal codes now: `query-argument-invalid`, `query-universe-unknown`, `query-chapter-ambiguous`,
|
||||
`query-item-too-large`. All four approved in the rulings.
|
||||
|
||||
### Grep results (grep only, not a test run)
|
||||
|
||||
- `git grep` over `packages/*/tests/*` and `scripts/*` for `query.mjs` and `index-build`: no hit outside
|
||||
`packages/library/tests/query*.test.mjs` and `index-build.test.mjs`.
|
||||
- 64-hex pins naming a library path: none. Positive control: the same pathspec finds the sha256 lines in
|
||||
`packages/cli/tests/case1-hostcheck.test.mjs` and the `packages/library/src/index.mjs` existence check at
|
||||
`packages/cli/tests/stack-copy.test.mjs:52`.
|
||||
- Tests that read `packages/library/src` as a directory: `packages/cli/tests/stack-release-fixture.mjs:309`
|
||||
copies `packages/library/src` whole into a release fixture. It carries no file list, so two new files break
|
||||
no assertion. `stack-release.test.mjs` is not in the gate unless Sage wants it.
|
||||
- Tests that import the library index as a namespace: `packages/bus/tests/library.test.mjs` and
|
||||
`library-sync.test.mjs`. Neither enumerates exports, so the two export lines add no break. Both run
|
||||
whole in phase B.
|
||||
|
||||
### Run limits (fixed before any run)
|
||||
|
||||
Every node file runs as its own `setsid -w timeout -k 10 LIMIT node --test FILE`; nothing runs unbounded.
|
||||
|
||||
- Node test files: LIMIT 900 s. The library test files have no recorded time. 900 s is above the longest
|
||||
recorded whole-package run I know of (cli, 591 s, row 281 BUILD.md "Runs" step 3), so it holds the 3x rule
|
||||
for any single file. The first run records each file's time, and a file past 300 s gets a limit re-derived
|
||||
from it before the next run. `query.test.mjs` builds a 900-file repo and a 3,000-file index rebuild is in
|
||||
`index-build.test.mjs`; both are the files most likely to need it.
|
||||
- No shell script is in this row's gate, so no script limit applies.
|
||||
|
||||
Any timeout goes to Sage, then Mos, unplaced (mos-1275).
|
||||
|
||||
### Phase B (not run; Sage's CLEAR first, then a rebase onto 253's tip)
|
||||
|
||||
```
|
||||
export TMPDIR=/mnt/storage/scratch/tmp
|
||||
cd <worktree>/packages/library
|
||||
for f in tests/*.test.mjs; do setsid -w timeout -k 10 900 node --test "$f"; done
|
||||
cd ../bus
|
||||
for f in tests/library.test.mjs tests/library-sync.test.mjs; do setsid -w timeout -k 10 900 node --test "$f"; done
|
||||
cd ../cli
|
||||
setsid -w timeout -k 10 900 node --test tests/stack-copy.test.mjs
|
||||
```
|
||||
|
||||
Mutants I propose (my list, not read off the brief; each is meant to be killed by the named test, and none
|
||||
has been thrown; the kill claims are unverified until CLEAR):
|
||||
|
||||
- M-a: `sees()` returns true for a file of another universe: killed by "find is bound to its universe".
|
||||
- M-b: the playground filter dropped: killed by "playground appears only when asked for".
|
||||
- M-c: the hybrid reads a source's playground: killed by "a hybrid gets a source playground file as null".
|
||||
- M-d: `links_next` always null: killed by the hub paging test.
|
||||
- M-e: the book filter lets `book: null` through: killed by the PRD 1.14 book test.
|
||||
|
||||
### Status
|
||||
|
||||
Built, not run. `node --check` has not been run on this round's files (no node before the CLEAR); the
|
||||
phase A pass above covered the earlier text only. The commit is local and waits for no gate under Sage's
|
||||
sage-2716 instruction, which allows git and docs work only; push and claim wait for Sage's go.
|
||||
@@ -1,139 +0,0 @@
|
||||
# Row 252 build notes: Console inbox Approve and Deny for grant requests
|
||||
|
||||
Seat: Scrooge. Reviewer: Webby. Issue #1753, Vikunja #435 (J2).
|
||||
Brief: `docs/plans/2026-10-21_console-inbox-approve.md` (blob b41676d06).
|
||||
Design: Gander's DESIGN.md at d218e14bd, approved by Webby at 5d8f0f4b5 (webby-0082) with manifest checks N1-N5.
|
||||
Base: aca16404c on branch `scrooge/row252`. Rebase onto the row 253 tip under a new branch name once 253 lands.
|
||||
|
||||
## Status
|
||||
|
||||
- Code, tests and docs are written. No test has been run yet, because Gander holds the gate slot for row 253 run 5 and Sage ruled no node until a CLEAR.
|
||||
- Everything in the Results section is empty until that run. Nothing below claims a pass.
|
||||
|
||||
## Q188
|
||||
|
||||
No agent creates a grant. Approve and Deny are human-only Console actions.
|
||||
|
||||
- Only a console cap reaches `decision.preview.console` and `decision.resolve.console`. A console cap reaches nothing else (console-decide `J1, Q188`).
|
||||
- The socket refuses a console proof for any other verb before the proof check (`server.mjs`).
|
||||
- A console cap binds only through `authenticateConsole`, which needs the console proof (section 2): the same-tree `console-cli.mjs` wrapper, its own nonce, a direct same-tree `serve` parent, and a configured owner subject from the spawn environment.
|
||||
- `recordEvent` keeps `human.input` reserved, so no agent writes the record that lets a console close start the executor (console-decide `recordEvent keeps human.input reserved`).
|
||||
- A `via console` close without its matching `human.input` approves nothing (console-decide `J14`).
|
||||
|
||||
## Manifest checks N1-N5
|
||||
|
||||
- **N1.** Every `console.sso` subject is an owner of the business. Name is display only.
|
||||
- `cli/src/config.mjs` carries `{provider, iss, sub}` per subject into the boot as `consoleOwners`. No sso means no key.
|
||||
- `bus/src/runtime.mjs` validates the list (array, at most 512, exactly three non-empty strings each) and passes it to `verifyPrincipal`.
|
||||
- Tests: cli config.test `Row 252 (Webby N1)` and principal.test.
|
||||
- **N2.** `human.input` is written in the same transaction, before `#approvedGrant`. The executor starts once, and a deny starts none.
|
||||
- Tests: console-decide `N2, J13` checks the order by seq and that the grant runs once. `J15` checks that a deny starts nothing.
|
||||
- **N3.** The close notice comes from `from_run 'human-console'`, and `public/bus.js` shows "via console". `landing` and `#protectedEnd` stay CLI-only.
|
||||
- Tests: console-decide `N2, J13` checks the notice. decide-browser `J17` checks the close-elsewhere line.
|
||||
- **N4.** `decision.resolve` is not a Console verb.
|
||||
- `CONSOLE_VERBS` is exactly the two console verbs. The webui serve `BUS_VERBS` read list has no decision verb, and webui `cli.mjs` has no `decision.` string. `consoleCall` refuses anything outside `CONSOLE_VERBS`.
|
||||
- Tests: console-decide `N4`, and decide-sso `N4`, which hits `/api/bus/decision.*` for 404 and covers the cli.mjs scan and the consoleCall refusals.
|
||||
- **N5.** `readProcess` keeps the five `MOSAIC_BUS_CONSOLE_*` keys in the env allowlist (`bus/src/human.mjs`). The human proof and its spawn are unchanged.
|
||||
|
||||
## Design choices and tradeoffs
|
||||
|
||||
- **Webui imports from the broker.** `webui/src/bus.mjs` imports `CONSOLE_VERBS` from `bus/src/broker.mjs`, which loads the broker module into the Console at import.
|
||||
- I chose one source for the verb list over a second copy that could drift.
|
||||
- If Webby prefers the copy, the alternative is a literal list plus a test pinning it equal to `CONSOLE_VERBS`.
|
||||
- **The Console transport shares humanCall's spawn.** `consoleCall` and `humanCall` share it as `transport()`, so `webui/src/bus.mjs` changed. The busCaller `business` default keeps `||` (the old falsy semantics), not `??`.
|
||||
- **Confirmations are held in memory in the serve process.** Their lifetime is 2 minutes, they are single use, and they bind the session, decision, choice and digest. The cap is a limit that never drops a live confirmation.
|
||||
- A restart loses all held confirmations. The user clicks again and gets a new preview. Nothing is written until the resolve.
|
||||
- **No retry anywhere.** A resolve whose bus reply is lost answers `outcome-unknown`, the confirmation is spent, and the page says "Nothing was sent again."
|
||||
|
||||
## Old tests changed (for Mos, per mos-1272)
|
||||
|
||||
Mos ruled both changes allowed in mos-1321 (17:12:28Z), each with one condition.
|
||||
- **shell.test condition:** reword the comment that opened "No Resolve control", since it is false once the row lands. It now says row 252's Approve and Deny is the one decision control, that its labels come from ACTIONS, and that the doesNotMatch keeps any literal label out of the markup. Webby checks the wording in review.
|
||||
- **channel-proof condition:** a header comment is not enough, because human.mjs changes what readProcess keeps. In the review, Webby reads the human.mjs and webui bus.mjs diffs and states two things:
|
||||
- the human proof's checks are unchanged
|
||||
- only the console verifier reads the five MOSAIC_BUS_CONSOLE_* keys, and each case is covered by a named test in console-decide or decide-sso
|
||||
- **Second row to land (252 or 239):** it recomputes the webui bus.mjs hash, adds its own header line, and its reviewer re-reads bus.mjs from the base to the combined tip.
|
||||
|
||||
- **`webui/tests/shell.test.mjs`**, write-boundary test. Allowed under mos-1321, with the comment reworded.
|
||||
- The `writes` list gains the new `fetch(url, ...)` in `decidePost`. It is first in file order (bus.js:362).
|
||||
- The `method:` count goes from 7 to 8.
|
||||
- A new assertion pins `decidePost`'s callers to `url` plus the two literal `/api/decisions/${enc(a.id)}/...` routes.
|
||||
- No existing entry was loosened.
|
||||
- **`bus/tests/channel-proof.test.mjs`**, sha256 re-pins with a header comment:
|
||||
- `bus/src/human.mjs` (N5 env keys) to bb13c146…c885.
|
||||
- `webui/src/bus.mjs` (consoleCall and `transport()`) to 701f009e…5272.
|
||||
- Any later edit to either file needs a re-pin.
|
||||
- `bus/tests/principal.test.mjs`, `webui/tests/tester-listener.test.mjs` and `cli/tests/config.test.mjs` have new tests appended only.
|
||||
|
||||
## Overlap with row 239 (Honker, b0c28366f, sage-2835)
|
||||
|
||||
- Row 239 also re-pins `channel-proof.test.mjs`:
|
||||
- `packages/webui/src/bus.mjs`, to 1eba9774…e5cf. That is the pin both rows change.
|
||||
- `packages/cli/src/transport.mjs`, which is 239 only.
|
||||
- `packages/bus/src/human.mjs` is a 252-only pin.
|
||||
- The two rows also share source files: `bus/src/broker.mjs`, `bus/src/runtime.mjs`, `webui/src/bus.mjs` and `webui/src/serve.mjs`.
|
||||
- Whichever row lands second rebases onto the first and recomputes the webui `bus.mjs` pin from the merged file. Both header comments are kept. It then reruns every touched test file whole.
|
||||
- Mos needs to see that re-pin (re-pins need Mos).
|
||||
|
||||
## Mutant plan (design section 6, J1-J17)
|
||||
|
||||
Each mutant is one edit to the named source. Run the killing test, then restore and compare the sha256 against the pre-mutant hash with `git status` clean apart from the build. Kill rules are written before the runs.
|
||||
|
||||
| J | Mutant | Source | Expected kill |
|
||||
|---|---|---|---|
|
||||
| J1 | eligibility from the request, `#consoleResolution` returns non-null for any decision | broker.mjs | console-decide `J1, J2` (ineligible) |
|
||||
| J2 | hardcode `approve = yes`, `deny = no` | broker.mjs | console-decide `J2` (go/stop, deny listed first) |
|
||||
| J3 | owner from name or header, skip the owner match in verifyPrincipal | principal.mjs | principal.test `console requires…` |
|
||||
| J4 | skip the serve-parent walk | principal.mjs | principal.test `console requires…` |
|
||||
| J5 | accept the human nonce namespace | principal.mjs | principal.test `console requires…` |
|
||||
| J6 | reuse the session from the confirmation, skip the live session check at resolve | decision-http.mjs | decision-http `session that ends`, decide-sso `listener gates` (new session) |
|
||||
| J7 | keep the console cap bound after the call (skip `release` for kind console) | server.mjs | **open**: no new test pins release for a console proof. Before the run, check whether an existing server test kills it. If not, add one and name it in the report. |
|
||||
| J8 | skip the same-origin check | decision-http.mjs | decision-http `gates`, decide-sso `listener gates` |
|
||||
| J9 | resolve without a confirmation token | decision-http.mjs | decision-http `a confirmation then a resolve`, `bad body` |
|
||||
| J10 | drop the choice, decision or session binding | decision-http.mjs | decision-http `binds the session, decision and choice` |
|
||||
| J11 | skip the digest, closed or business check | broker.mjs | console-decide `J11`, `N2, J13` (second answer closed) |
|
||||
| J12 | do not spend the confirmation | decision-http.mjs | decision-http `a second resolve refuses`, `confirmations… one use` |
|
||||
| J13 | write via cli, or omit the subject in human.input | broker.mjs | console-decide `N2, J13` |
|
||||
| J14 | `#approvedGrant` accepts via console without the human.input | broker.mjs | console-decide `J14` |
|
||||
| J15 | start the executor on any resolved choice | broker.mjs | console-decide `J15` and `J2` (stop starts nothing) |
|
||||
| J16 | `decide` true without sso or send | serve.mjs, public/bus.js | decide-sso `J16`, decide-browser `J16` |
|
||||
| J17 | retry on outcome-unknown or auto-submit after preview | public/bus.js | decide-browser `J17` (post count stays at 7) |
|
||||
|
||||
## Manifest
|
||||
|
||||
Source:
|
||||
- packages/bus/src/broker.mjs
|
||||
- packages/bus/src/client.mjs
|
||||
- packages/bus/src/console-cli.mjs (new)
|
||||
- packages/bus/src/human.mjs
|
||||
- packages/bus/src/principal.mjs
|
||||
- packages/bus/src/runtime.mjs
|
||||
- packages/bus/src/server.mjs
|
||||
- packages/cli/src/config.mjs
|
||||
- packages/webui/src/bus.mjs
|
||||
- packages/webui/src/cli.mjs
|
||||
- packages/webui/src/decision-http.mjs (new)
|
||||
- packages/webui/src/public/bus.js
|
||||
- packages/webui/src/serve.mjs
|
||||
|
||||
Tests:
|
||||
- packages/bus/tests/channel-proof.test.mjs (re-pins)
|
||||
- packages/bus/tests/console-decide.test.mjs (new)
|
||||
- packages/bus/tests/principal.test.mjs
|
||||
- packages/cli/tests/config.test.mjs
|
||||
- packages/webui/tests/decide-browser.test.mjs (new)
|
||||
- packages/webui/tests/decide-sso.test.mjs (new)
|
||||
- packages/webui/tests/decision-http.test.mjs (new)
|
||||
- packages/webui/tests/shell.test.mjs (old test changed, see above)
|
||||
- packages/webui/tests/tester-listener.test.mjs
|
||||
|
||||
Docs:
|
||||
- packages/webui/README.md
|
||||
- agents/scrooge/work/queue-252/BUILD.md (this file)
|
||||
|
||||
## Results
|
||||
|
||||
Not run. At the first CLEAR:
|
||||
1. Run each touched test file whole, with TMPDIR under /mnt/storage/scratch/tmp.
|
||||
2. Run the bus, webui and cli suites.
|
||||
3. Run the mutants above, then the drift check against refactor.
|
||||
+67
-142
@@ -96,7 +96,16 @@ export const TASK_PAGE_MAX = 200;
|
||||
// request allowed past the ordinary 64 KiB envelope (server.mjs and client.mjs), up to its content cap.
|
||||
export const PROJECT_FILE_VERBS = Object.freeze(['project.files', 'project.file', 'project.file.save']);
|
||||
// C2-03a: library.sync is the human's fetch, and with `accept` the one way past library-diverged.
|
||||
export const LIBRARY_VERBS = Object.freeze(['library.file', 'library.status', 'library.sync']);
|
||||
// Row 224: pure agent lookups are separate from the three human-only verbs. Listing them
|
||||
// here also makes server.mjs await their host effects instead of serializing a Promise as {}.
|
||||
const LIBRARY_LOOKUPS = Object.freeze({
|
||||
'library.find': { method: 'find', fields: ['universe', 'query', 'type', 'book', 'scope', 'offset'], required: ['universe', 'query'] },
|
||||
'library.get': { method: 'get', fields: ['universe', 'path', 'from', 'links_from'], required: ['universe', 'path'] },
|
||||
'library.search': { method: 'search', fields: ['universe', 'text', 'book', 'scope', 'offset'], required: ['universe', 'text'] },
|
||||
'library.manuscript': { method: 'manuscript', fields: ['universe', 'book', 'scope', 'chapter', 'from', 'to'], required: ['universe', 'book'] },
|
||||
'library.universe': { method: 'universeInfo', fields: ['universe'], required: ['universe'] },
|
||||
});
|
||||
export const LIBRARY_VERBS = Object.freeze(['library.file', 'library.status', 'library.sync', ...Object.keys(LIBRARY_LOOKUPS)]);
|
||||
export const PROJECT_SAVE_VERB = 'project.file.save';
|
||||
export const PROJECT_SAVE_REQUEST_MAX = 1024 * 1024 + 64 * 1024;
|
||||
export const WEB_VERBS =Object.freeze(['web.search', 'web.fetch']);
|
||||
@@ -132,9 +141,6 @@ export const TESTER_VERBS = Object.freeze(['message.send', 'message.receive', 'm
|
||||
// refuses a user access-refused before its own checks: owner verbs, board reads, message.*,
|
||||
// decision.*, project files, spec.read, assistant.stop, launch.*, name.set and usage.*.
|
||||
export const USER_VERBS = Object.freeze(['project.list', 'thread.create', 'thread.list', 'thread.open', 'thread.send', 'thread.messages', 'names']);
|
||||
// Row 252 (#1753, design sections 2-3): the only verbs a Console owner cap reaches. Every other verb
|
||||
// refuses it human-required before its own checks, and these refuse every other cap.
|
||||
export const CONSOLE_VERBS = Object.freeze(['decision.preview.console', 'decision.resolve.console']);
|
||||
// The level a user's grant must reach for USER_VERBS (R1's covers, morgana-0005).
|
||||
const USER_LEVEL = 'collaborator';
|
||||
// Row 175: the organization verbs a tester may call. Its writes run every check the human's do
|
||||
@@ -1025,26 +1031,6 @@ export class Broker {
|
||||
if (!this.#userGrants(s).length) fail('access-revoked');
|
||||
return this.#cap(s);
|
||||
}
|
||||
// Row 252 (#1753, design section 2): the trusted Console transport invokes this only after
|
||||
// verifyPrincipal checked the console-cli wrapper, its webui `serve` parent and the signed-in owner.
|
||||
// The cap is the business human's with via 'console' and the owner subject. request() lets it reach
|
||||
// CONSOLE_VERBS only, and #human refuses it, so no cli verb accepts it.
|
||||
bindConsole(record) {
|
||||
keys(record, ['business', 'human', 'subject'], ['business', 'human', 'subject']);
|
||||
const b = this.#business(record.business);
|
||||
const x = record.subject;
|
||||
if (record.human !== b.human || !object(x) || Object.keys(x).length !== 3 ||
|
||||
!['provider', 'iss', 'sub'].every((k) => typeof x[k] === 'string' && x[k].length > 0 && x[k].length <= 512))
|
||||
fail('human-required');
|
||||
return this.#cap({
|
||||
business: record.business,
|
||||
human: b.human,
|
||||
via: 'console',
|
||||
console: Object.freeze({ provider: x.provider, iss: x.iss, sub: x.sub }),
|
||||
role: null,
|
||||
run: null,
|
||||
});
|
||||
}
|
||||
bindReader({ business }) {
|
||||
this.#business(business);
|
||||
return this.#cap({ business, reader: true, role: null, run: null, human: false });
|
||||
@@ -1093,61 +1079,10 @@ export class Broker {
|
||||
const raise = this.#raised(business, decision);
|
||||
if (d?.action !== 'credential.mint' || d.route_to !== 'human' || !raise?.grant) return null;
|
||||
const r = this.#closed(decision);
|
||||
if (r?.op !== 'resolved' || r.choice !== raise.approvalChoice || r.by !== this.#business(business).human) return null;
|
||||
// Row 252 (#1753, design section 3, J14): a console close counts only with its own human.input,
|
||||
// which only the broker writes (recordEvent reserves the kind).
|
||||
if (!(r.via === 'console' ? this.#consoleInput(business, decision, r) : r.via === 'cli')) return null;
|
||||
if (r?.op !== 'resolved' || r.choice !== raise.approvalChoice || r.via !== 'cli' || r.by !== this.#business(business).human)
|
||||
return null;
|
||||
return raise.grant;
|
||||
}
|
||||
// Row 252: the console answer's human.input for close row r. Same decision, same seq, same choice,
|
||||
// no role or run, and a three-string owner subject.
|
||||
#consoleInput(business, decision, r) {
|
||||
const e = this.#store.get(
|
||||
`SELECT body FROM events WHERE business=? AND kind='human.input' AND subject=? AND actor_role IS NULL AND actor_run IS NULL
|
||||
AND json_extract(body,'$.via')='console' AND json_extract(body,'$.decisionSeq')=? ORDER BY seq LIMIT 1`,
|
||||
business,
|
||||
decision,
|
||||
r.seq,
|
||||
);
|
||||
const x = e && JSON.parse(e.body);
|
||||
return (
|
||||
!!x &&
|
||||
x.kind === 'answer' &&
|
||||
x.decision === decision &&
|
||||
x.op === 'resolved' &&
|
||||
x.choice === r.choice &&
|
||||
object(x.subject) &&
|
||||
['provider', 'iss', 'sub'].every((k) => typeof x.subject[k] === 'string' && x.subject[k].length > 0)
|
||||
);
|
||||
}
|
||||
// Row 252 (design section 1): a Console-eligible grant request, or null. Open is the caller's
|
||||
// check. Eligible is route_to human, action credential.mint, a raise with a validated grant binding
|
||||
// and exactly two distinct option keys, one of them approvalChoice. Approve is that key and deny is
|
||||
// the other. The summary carries only the binding's method, agent, host and target projects. The
|
||||
// digest binds the id, business, options, approval key and binding, so any change reads stale.
|
||||
#consoleResolution(d) {
|
||||
if (d.route_to !== 'human' || d.action !== 'credential.mint') return null;
|
||||
const raise = this.#raised(d.business, d.id);
|
||||
if (!object(raise?.grant) || typeof raise.grant.method !== 'string') return null;
|
||||
const options = JSON.parse(d.options);
|
||||
if (options.length !== 2 || options[0].key === options[1].key) return null;
|
||||
const approve = options.find((o) => o.key === raise.approvalChoice);
|
||||
if (!approve) return null;
|
||||
const deny = options.find((o) => o.key !== raise.approvalChoice);
|
||||
const g = raise.grant;
|
||||
const grant = {
|
||||
method: g.method,
|
||||
...(typeof g.agent === 'string' ? { agent: g.agent } : {}),
|
||||
...(typeof g.host === 'string' ? { host: g.host } : {}),
|
||||
...(Array.isArray(g.target?.projects) ? { projects: g.target.projects.filter((p) => typeof p === 'string') } : {}),
|
||||
};
|
||||
return {
|
||||
approve: { key: approve.key, text: approve.text },
|
||||
deny: { key: deny.key, text: deny.text },
|
||||
grant,
|
||||
digest: sha256(JSON.stringify([d.id, d.business, options, raise.approvalChoice, raise.grant])),
|
||||
};
|
||||
}
|
||||
// r6 X1: the executor's own spend. The caller owns the transaction. The consume record is the only
|
||||
// guarded body with `decision` (#436b section 7), so #spent counts it.
|
||||
#consumeGrant(business, decision, method) {
|
||||
@@ -4186,9 +4121,6 @@ export class Broker {
|
||||
grants = this.#userGrants(s);
|
||||
if (!grants.length) fail('access-revoked');
|
||||
}
|
||||
// Row 252 (design section 2, N4): a console cap reaches the two console verbs only, and nothing
|
||||
// else reaches them. decision.resolve stays off the list.
|
||||
if (!!s.console !== CONSOLE_VERBS.includes(request.verb)) fail('human-required');
|
||||
const args = request.args ?? {};
|
||||
if (!object(args)) fail('invalid-request');
|
||||
this.#secretCheck(args);
|
||||
@@ -4678,10 +4610,42 @@ export class Broker {
|
||||
throw this.#refused(s, ProjectFailure && e instanceof ProjectFailure ? new BusError(e.code) : e, request);
|
||||
}
|
||||
}
|
||||
// Row 224 C2-05b: the agent never supplies a host repo, commit or business. Its current
|
||||
// holder and business entry are rechecked before/after awaits; runtime owns Git and queries.
|
||||
async #requestLibraryLookup(s, { verb, args }) {
|
||||
try {
|
||||
this.#agent(s);
|
||||
const business = this.#business(s.business);
|
||||
const projects = Object.entries(business.projects ?? {}).filter(([, entry]) => entry.kind === 'library');
|
||||
if (!projects.length || typeof this.#library?.lookup !== 'function') fail('library-not-configured');
|
||||
const spec = LIBRARY_LOOKUPS[verb];
|
||||
keys(args, ['project', ...spec.fields], spec.required);
|
||||
if (!text(args.universe, 160)) fail('invalid-request');
|
||||
if (args.project === undefined && projects.length !== 1) fail('invalid-request');
|
||||
const project = args.project ?? projects[0][0];
|
||||
if (!text(project, 160) || !projects.some(([name]) => name === project)) fail('library-not-configured');
|
||||
const revision = JSON.stringify(business.projects[project]);
|
||||
const authorize = () => {
|
||||
this.#agent(s);
|
||||
this.#processingGate(s, verb);
|
||||
if (JSON.stringify(this.#business(s.business).projects?.[project]) !== revision) fail('library-not-configured');
|
||||
};
|
||||
const { project: ignored, ...query } = args;
|
||||
authorize();
|
||||
const result = await this.#library.lookup({ business, project }, { method: spec.method, args: query, authorize });
|
||||
authorize();
|
||||
this.#secretCheck(result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const Failure = this.#library?.LibraryError;
|
||||
throw this.#refused(s, Failure && error instanceof Failure ? new BusError(error.code) : error, { verb, args });
|
||||
}
|
||||
}
|
||||
// C2-04 M4: only a verified human reads library text or status. Host Git
|
||||
// functions stay injected, preserving this module's sandbox import closure.
|
||||
async #requestLibrary(s, request) {
|
||||
const { verb, args } = request;
|
||||
if (Object.hasOwn(LIBRARY_LOOKUPS, verb)) return this.#requestLibraryLookup(s, request);
|
||||
try {
|
||||
this.#human(s);
|
||||
const fields = verb === 'library.file' ? ['project', 'path'] : verb === 'library.sync' ? ['project', 'accept'] : ['project'];
|
||||
@@ -4711,7 +4675,6 @@ export class Broker {
|
||||
const s = this.#session(cap);
|
||||
try {
|
||||
if (s.user) fail('access-refused');
|
||||
if (s.console) fail('human-required');
|
||||
this.#processingWork();
|
||||
keys(request, ['verb', 'args'], ['verb']);
|
||||
if (!TASK_VERBS.includes(request.verb)) fail('unknown-verb');
|
||||
@@ -4750,7 +4713,6 @@ export class Broker {
|
||||
let args;
|
||||
try {
|
||||
if (s.user) fail('access-refused');
|
||||
if (s.console) fail('human-required');
|
||||
keys(request, ['verb', 'args'], ['verb']);
|
||||
this.#processingWork();
|
||||
if (!verb) fail('unknown-verb');
|
||||
@@ -4810,7 +4772,6 @@ export class Broker {
|
||||
let a, entry, timeout, bound;
|
||||
try {
|
||||
if (s.user) fail('access-refused');
|
||||
if (s.console) fail('human-required');
|
||||
this.#processingWork();
|
||||
keys(request, ['verb', 'args'], ['verb']);
|
||||
if (!HOST_VERBS.includes(request.verb)) fail('unknown-verb');
|
||||
@@ -5127,38 +5088,15 @@ export class Broker {
|
||||
}
|
||||
return this.#decisionView(this.#decision(s, did));
|
||||
}
|
||||
// Row 252 (#1753, design section 3): the Console's read of one grant request before its
|
||||
// confirmation. It returns the two answers, a safe grant summary and the digest the answer must
|
||||
// carry. Anything not eligible refuses, so the browser never decides eligibility (J1).
|
||||
case 'decision.preview.console': {
|
||||
keys(a, ['id'], ['id']);
|
||||
identifier(a.id);
|
||||
if (!s.console || s.human !== b.human) fail('human-required');
|
||||
const d = this.#decision(s, a.id);
|
||||
if (this.#closed(d.id)) fail('decision-closed');
|
||||
const c = this.#consoleResolution(d);
|
||||
if (!c) fail('decision-not-eligible');
|
||||
return { id: d.id, business: d.business, question: d.question, ...c };
|
||||
}
|
||||
case 'decision.resolve.console':
|
||||
case 'decision.resolve':
|
||||
case 'decision.seen':
|
||||
case 'decision.withdraw': {
|
||||
const viaConsole = verb === 'decision.resolve.console';
|
||||
if (viaConsole) keys(a, ['id', 'choice', 'digest'], ['id', 'choice', 'digest']);
|
||||
else keys(a, ['id', 'choice', 'note'], ['id']);
|
||||
keys(a, ['id', 'choice', 'note'], ['id']);
|
||||
identifier(a.id);
|
||||
if (a.note !== undefined) string(a.note);
|
||||
const d = this.#decision(s, a.id);
|
||||
if (this.#closed(d.id)) fail('decision-closed');
|
||||
// Row 252 (design section 3): the Console answer rechecks eligibility, the digest and the choice
|
||||
// in this transaction. It answers one of the two keys, never a key the browser names (J2).
|
||||
if (viaConsole) {
|
||||
if (!s.console || s.human !== b.human) fail('human-required');
|
||||
const c = this.#consoleResolution(d);
|
||||
if (!c) fail('decision-not-eligible');
|
||||
if (a.digest !== c.digest || ![c.approve.key, c.deny.key].includes(a.choice)) fail('decision-stale');
|
||||
} else if (verb === 'decision.withdraw') {
|
||||
if (verb === 'decision.withdraw') {
|
||||
if (s.human) {
|
||||
this.#human(s);
|
||||
} else {
|
||||
@@ -5170,39 +5108,32 @@ export class Broker {
|
||||
this.#agent(s);
|
||||
if (s.role !== d.route_to) fail('not-resolver');
|
||||
}
|
||||
const op = verb === 'decision.resolve' || viaConsole ? 'resolved' : verb === 'decision.seen' ? 'seen' : 'withdrawn';
|
||||
const op = verb === 'decision.resolve' ? 'resolved' : verb === 'decision.seen' ? 'seen' : 'withdrawn';
|
||||
if (op === 'resolved' && !JSON.parse(d.options).some((o) => o.key === a.choice))
|
||||
fail('invalid-choice');
|
||||
const seq = Number(
|
||||
this.#store.run(
|
||||
'INSERT INTO decision_events(decision,at,op,by,choice,note,via) VALUES(?,?,?,?,?,?,?)',
|
||||
d.id,
|
||||
this.#now(),
|
||||
op,
|
||||
by,
|
||||
op === 'resolved' ? a.choice : null,
|
||||
a.note ?? null,
|
||||
s.console ? 'console' : s.human ? 'cli' : 'broker',
|
||||
).lastInsertRowid,
|
||||
this.#store.run(
|
||||
'INSERT INTO decision_events(decision,at,op,by,choice,note,via) VALUES(?,?,?,?,?,?,?)',
|
||||
d.id,
|
||||
this.#now(),
|
||||
op,
|
||||
by,
|
||||
op === 'resolved' ? a.choice : null,
|
||||
a.note ?? null,
|
||||
s.human ? 'cli' : 'broker',
|
||||
);
|
||||
// Row 252 (N2): the human.input comes before #approvedGrant, which needs a console answer's
|
||||
// record to start the executor. Its body names the owner subject and the close's seq.
|
||||
if (s.human)
|
||||
this.#event(
|
||||
s,
|
||||
'human.input',
|
||||
s.console
|
||||
? { kind: 'answer', decision: d.id, op, choice: a.choice, via: 'console', subject: s.console, decisionSeq: seq }
|
||||
: { kind: 'answer', decision: d.id, op, choice: a.choice ?? null },
|
||||
d.id,
|
||||
);
|
||||
// Row 234 (#1740, r6 X1): the human's cli approve of a grant mint starts the executor once this
|
||||
// transaction commits. It rechecks all of this itself, so a rollback starts nothing. Row 252: a
|
||||
// Console approve does too. A deny closes the decision with the other key and starts nothing.
|
||||
// transaction commits. It rechecks all of this itself, so a rollback starts nothing.
|
||||
const grant = op === 'resolved' ? this.#approvedGrant(s.business, d.id) : null;
|
||||
if (grant?.method === 'host-enroll') setImmediate(() => this.#runEnrollment(s.business, d.id));
|
||||
if (grant?.method === 'host') setImmediate(() => this.#runHostGrant(s.business, d.id));
|
||||
if (grant?.method === 'gitea') setImmediate(() => this.#startGitea(s.business, d.id));
|
||||
if (s.human)
|
||||
this.#event(
|
||||
s,
|
||||
'human.input',
|
||||
{ kind: 'answer', decision: d.id, op, choice: a.choice ?? null },
|
||||
d.id,
|
||||
);
|
||||
// Row 115 (#1609): a close by anyone but the raising run tells the raising role, routed like message.send's.
|
||||
// Row 133 (#1627): no messaging check here. decision.raise refused a route that can't message the
|
||||
// raiser, definitions change only with a pinned copy and arbiters only through the business
|
||||
@@ -5229,7 +5160,7 @@ export class Broker {
|
||||
this.#now(),
|
||||
s.business,
|
||||
s.human ? 'human' : s.role,
|
||||
s.console ? 'human-console' : s.human ? 'human-cli' : s.run,
|
||||
s.human ? 'human-cli' : s.run,
|
||||
d.raised_by_role,
|
||||
'DECISION',
|
||||
null,
|
||||
@@ -5239,8 +5170,7 @@ export class Broker {
|
||||
);
|
||||
this.#route(s.business, mid, d.raised_by_role, session);
|
||||
}
|
||||
const answer = viaConsole ? { id: d.id, op, choice: a.choice } : { id: d.id, op };
|
||||
return grant ? { ...answer, grant: 'started' } : answer;
|
||||
return grant ? { id: d.id, op, grant: 'started' } : { id: d.id, op };
|
||||
}
|
||||
case 'message.send': {
|
||||
keys(a, ['to', 'body', 'class', 'in_reply_to', 'corrects', 'decision'], ['to', 'body']);
|
||||
@@ -5583,12 +5513,7 @@ export class Broker {
|
||||
s.business,
|
||||
s.human || s.reader ? 'human' : s.role,
|
||||
)
|
||||
.map((d) => {
|
||||
// Row 252: a grant request the Console can answer carries its two answers. The Console
|
||||
// shows buttons from this, and the answer path rechecks it at the bus.
|
||||
const c = s.human || s.reader ? this.#consoleResolution(d) : null;
|
||||
return c ? { ...this.#decisionView(d), console: c } : this.#decisionView(d);
|
||||
});
|
||||
.map((d) => this.#decisionView(d));
|
||||
if (verb === 'agents') {
|
||||
const claims = this.#store.all(
|
||||
"SELECT * FROM role_claims c WHERE business=? AND op='claim' AND seq=(SELECT max(seq) FROM role_claims WHERE business=c.business AND role=c.role) ORDER BY role",
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { connect } from 'node:net';
|
||||
import { BusError, PROJECT_SAVE_VERB, PROJECT_SAVE_REQUEST_MAX } from './broker.mjs';
|
||||
export class Client {
|
||||
constructor({ path, cap, human, tester, user, console: owner, timeout = 5000 }) {
|
||||
constructor({ path, cap, human, tester, user, timeout = 5000 }) {
|
||||
this.path = path;
|
||||
this.cap = cap;
|
||||
this.human = human;
|
||||
this.tester = tester;
|
||||
this.user = user;
|
||||
this.console = owner;
|
||||
this.timeout = timeout;
|
||||
}
|
||||
call(verb, args = {}) {
|
||||
const request =
|
||||
JSON.stringify({
|
||||
...(this.human ? { human: this.human } : this.tester ? { tester: this.tester } : this.user ? { user: this.user } : this.console ? { console: this.console } : { cap: this.cap }),
|
||||
...(this.human ? { human: this.human } : this.tester ? { tester: this.tester } : this.user ? { user: this.user } : { cap: this.cap }),
|
||||
verb,
|
||||
args,
|
||||
}) + '\n';
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
// Row 252 (#1753, design section 2): the Console owner's transport shim, spawned only by the webui
|
||||
// `cli.mjs serve` process for one preview or one answer. Not a second product CLI. The Console sets
|
||||
// the nonce, the business and the signed-in (provider, iss, sub) in the spawn environment, and the
|
||||
// bus reads them back from this process's initial environment. Nothing respawns here, so the
|
||||
// Console stays the direct parent. Input is a verb and its args only: a body that names a business,
|
||||
// a subject or any other key is refused, never merged.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { Client } from './client.mjs';
|
||||
import { readProcess } from './human.mjs';
|
||||
import { CONSOLE_VERBS } from './broker.mjs';
|
||||
const env = process.env;
|
||||
try {
|
||||
if (!/^[a-f0-9]{64}$/.test(env.MOSAIC_BUS_CONSOLE_NONCE ?? '')) throw Object.assign(Error(), { code: 'human-required' });
|
||||
const request = JSON.parse(readFileSync(0, 'utf8'));
|
||||
if (
|
||||
!request ||
|
||||
typeof request !== 'object' ||
|
||||
Array.isArray(request) ||
|
||||
Object.keys(request).some((k) => !['verb', 'args'].includes(k)) ||
|
||||
!CONSOLE_VERBS.includes(request.verb)
|
||||
)
|
||||
throw Error();
|
||||
const me = readProcess(process.pid);
|
||||
const client = new Client({
|
||||
path: process.argv[2],
|
||||
console: {
|
||||
business: env.MOSAIC_BUS_CONSOLE_BUSINESS,
|
||||
provider: env.MOSAIC_BUS_CONSOLE_PROVIDER,
|
||||
iss: env.MOSAIC_BUS_CONSOLE_ISS,
|
||||
sub: env.MOSAIC_BUS_CONSOLE_SUB,
|
||||
pid: me.pid,
|
||||
startTime: me.startTime,
|
||||
nonce: env.MOSAIC_BUS_CONSOLE_NONCE,
|
||||
},
|
||||
});
|
||||
const result = await client.call(request.verb, request.args ?? {});
|
||||
process.stdout.write(JSON.stringify(result) + '\n');
|
||||
} catch (e) {
|
||||
process.stderr.write((/^[a-z-]{1,64}$/.test(e.code ?? '') ? e.code : 'invalid-request') + '\n');
|
||||
process.exitCode = 2;
|
||||
}
|
||||
@@ -10,10 +10,6 @@ const MARKERS = [
|
||||
'CODEX_THREAD_ID',
|
||||
'PI_AGENT_DIR',
|
||||
];
|
||||
// Row 252 (#1753, Webby N5): the Console wrapper's nonce and the subject its spawn bound. The
|
||||
// verifier reads each from the wrapper's initial environment, never from its proof alone.
|
||||
export const CONSOLE_ENV = Object.freeze(['MOSAIC_BUS_CONSOLE_NONCE', 'MOSAIC_BUS_CONSOLE_BUSINESS', 'MOSAIC_BUS_CONSOLE_PROVIDER', 'MOSAIC_BUS_CONSOLE_ISS', 'MOSAIC_BUS_CONSOLE_SUB']);
|
||||
const NONCE_KEYS = ['MOSAIC_BUS_CLI_NONCE', 'MOSAIC_BUS_USER_NONCE', ...CONSOLE_ENV];
|
||||
const refuse = () => {
|
||||
throw new BusError('human-required');
|
||||
};
|
||||
@@ -35,7 +31,7 @@ export function readProcess(pid, { readFile = readFileSync, stat = lstatSync } =
|
||||
for (const entry of environment.split('\0')) {
|
||||
const i = entry.indexOf('=');
|
||||
const key = entry.slice(0, i);
|
||||
if (NONCE_KEYS.includes(key) || MARKERS.includes(key)) env[key] = entry.slice(i + 1);
|
||||
if (['MOSAIC_BUS_CLI_NONCE', 'MOSAIC_BUS_USER_NONCE'].includes(key) || MARKERS.includes(key)) env[key] = entry.slice(i + 1);
|
||||
}
|
||||
return {
|
||||
pid,
|
||||
|
||||
@@ -5,8 +5,8 @@ import { verifyTester, testerNonce } from './tester.mjs';
|
||||
|
||||
// Trusted transport API. Identity is held only by this connection's transient session.
|
||||
// User mapping and live grant authorization belong to R3, not to this verifier.
|
||||
export function verifyPrincipal(request, { peer, paths, businesses, launches = [], readProcess = defaultRead, readTesterNonce = testerNonce, consoleOwners = [] }) {
|
||||
const kinds = ['human', 'tester', 'user', 'console'].filter(k => request?.[k]);
|
||||
export function verifyPrincipal(request, { peer, paths, businesses, launches = [], readProcess = defaultRead, readTesterNonce = testerNonce }) {
|
||||
const kinds = ['human', 'tester', 'user'].filter(k => request?.[k]);
|
||||
if (kinds.length !== 1 || request.cap) throw new BusError('invalid-envelope');
|
||||
const kind = kinds[0], proof = request[kind];
|
||||
const error = kind === 'tester' ? 'tester-required' : 'human-required';
|
||||
@@ -16,7 +16,6 @@ export function verifyPrincipal(request, { peer, paths, businesses, launches = [
|
||||
const { business } = verifyTester(proof, { cliPath: paths.tester, launches, readProcess, readNonce: readTesterNonce });
|
||||
return { kind: 'tester', business, id: 'tester' };
|
||||
}
|
||||
if (kind === 'console') return verifyConsole(proof, { peer, paths, businesses, launches, readProcess, consoleOwners });
|
||||
let checked = proof, parent;
|
||||
if (kind === 'user') {
|
||||
if (!proof || typeof proof.user !== 'string' || !proof.user ||
|
||||
@@ -43,38 +42,3 @@ export function verifyPrincipal(request, { peer, paths, businesses, launches = [
|
||||
if (!id) throw new BusError('unknown-business');
|
||||
return { kind: 'owner', business, id };
|
||||
}
|
||||
|
||||
// Row 252 (#1753, design section 2): the Console owner. The same-tree console-cli.mjs wrapper, its own
|
||||
// nonce namespace and the human walk, with the exact same-tree webui `cli.mjs serve` as its direct
|
||||
// parent, read again after the walk as the user kind does. Business and (provider, iss, sub) must be
|
||||
// the wrapper's initial spawn environment and one of the checked console.sso subjects the runtime
|
||||
// was started with (Webby N1: every configured subject is an owner). The Console's mapping is trusted
|
||||
// as row 196 permits; the bus does not exchange an ID token itself.
|
||||
const SUBJECT_KEYS = ['provider', 'iss', 'sub'];
|
||||
const SUBJECT_ENV = { business: 'MOSAIC_BUS_CONSOLE_BUSINESS', provider: 'MOSAIC_BUS_CONSOLE_PROVIDER', iss: 'MOSAIC_BUS_CONSOLE_ISS', sub: 'MOSAIC_BUS_CONSOLE_SUB' };
|
||||
function verifyConsole(proof, { peer, paths, businesses, launches, readProcess, consoleOwners }) {
|
||||
const refuse = () => { throw new BusError('human-required'); };
|
||||
if (!proof || typeof proof !== 'object' ||
|
||||
Object.keys(proof).some(k => !['pid', 'startTime', 'nonce', 'business', ...SUBJECT_KEYS].includes(k)) ||
|
||||
SUBJECT_KEYS.some(k => typeof proof[k] !== 'string' || !proof[k] || proof[k].length > 512)) refuse();
|
||||
if (typeof paths.consoleCli !== 'string' || typeof paths.console !== 'string') refuse();
|
||||
const { provider, iss, sub, ...checked } = proof;
|
||||
let child, parent;
|
||||
try {
|
||||
child = readProcess(proof.pid);
|
||||
parent = readProcess(child.ppid);
|
||||
if (parent.uid !== peer.uid || parent.argv[1] !== resolve(paths.console) || parent.argv[2] !== 'serve') refuse();
|
||||
for (const [key, name] of Object.entries(SUBJECT_ENV)) if (child.env?.[name] !== proof[key]) refuse();
|
||||
} catch { refuse(); }
|
||||
const { business } = verifyHuman(checked, { cliPath: paths.consoleCli, launches, readProcess, nonceKey: 'MOSAIC_BUS_CONSOLE_NONCE' });
|
||||
try {
|
||||
const again = readProcess(proof.pid), up = readProcess(parent.pid);
|
||||
if (again.ppid !== parent.pid || again.startTime !== child.startTime || up.startTime !== parent.startTime ||
|
||||
up.ppid !== parent.ppid || up.uid !== parent.uid || up.argv[1] !== parent.argv[1] || up.argv[2] !== 'serve') refuse();
|
||||
for (const [key, name] of Object.entries(SUBJECT_ENV)) if (again.env?.[name] !== proof[key]) refuse();
|
||||
} catch { refuse(); }
|
||||
if (!Array.isArray(consoleOwners) || !consoleOwners.some(o => o && o.provider === provider && o.iss === iss && o.sub === sub)) refuse();
|
||||
const id = businesses[business]?.human;
|
||||
if (!id) throw new BusError('unknown-business');
|
||||
return { kind: 'console', business, id, via: 'console', subject: { provider, iss, sub } };
|
||||
}
|
||||
@@ -84,6 +84,38 @@ export async function harnessLevels({ log = (e) => process.stderr.write(`plugins
|
||||
}
|
||||
return (name) => levels.get(name) ?? null;
|
||||
}
|
||||
// Row 224 C2-05b: a host-only adapter. A failed fetch keeps serving immutable old
|
||||
// objects, as C2-03a requires; no caller may select a repo, commit or sync --accept.
|
||||
export function createLibraryLookup({ dataRoot, sync, now = () => Date.now() }) {
|
||||
const methods = { find: libraryModule.find, get: libraryModule.get, search: libraryModule.search,
|
||||
manuscript: libraryModule.manuscript, universeInfo: libraryModule.universeInfo };
|
||||
return async (context, { method, args, authorize }) => {
|
||||
if (!Object.hasOwn(methods, method)) throw new libraryModule.LibraryError('query-argument-invalid', 'unknown lookup');
|
||||
authorize();
|
||||
const source = { ...context, dataRoot };
|
||||
let state = await libraryModule.libraryStatus(source);
|
||||
authorize();
|
||||
// sage-2819 / dewey-0852: failed attempts also pace inline retries. Otherwise an
|
||||
// outage makes every lookup refetch while lastFetch remains old or null.
|
||||
const recentAttempt = Math.max(state.lastFetch === null ? -Infinity : Date.parse(state.lastFetch),
|
||||
state.lastError === null ? -Infinity : Date.parse(state.lastError.at));
|
||||
const age = now() - recentAttempt;
|
||||
if (age > libraryModule.SYNC_INTERVAL_MS || age < 0) {
|
||||
try { await sync(source); }
|
||||
catch (error) {
|
||||
// A timer may already be fetching this mirror. Its current served state is still safe.
|
||||
if (!(error instanceof libraryModule.LibraryError) || error.code !== 'library-sync-busy') throw error;
|
||||
}
|
||||
authorize();
|
||||
state = await libraryModule.libraryStatus(source);
|
||||
}
|
||||
if (!state.served) throw new libraryModule.LibraryError('library-commit', 'library has never been synced');
|
||||
authorize();
|
||||
return methods[method]({ repo: libraryModule.mirrorPath(source), commit: state.served,
|
||||
mapping: libraryModule.checkMapping(context.business.projects[context.project]) }, args);
|
||||
};
|
||||
}
|
||||
|
||||
export async function startBroker({
|
||||
dataRoot,
|
||||
businesses,
|
||||
@@ -102,15 +134,7 @@ export async function startBroker({
|
||||
// FED R3 (#414): the access file and its loader context, { file, context } (R1's loadAccess).
|
||||
// Without it no user binds. R6 and R8 pass it; the live boot config doesn't yet.
|
||||
access = null,
|
||||
// Row 252 (#1753, design section 2): the checked console.sso subjects, [{provider, iss, sub}], each an
|
||||
// owner of the business (Webby N1). The boot config carries them; no socket verb sets them. Empty, no
|
||||
// Console owner binds and the terminal owner is unchanged.
|
||||
consoleOwners = [],
|
||||
}) {
|
||||
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');
|
||||
const owners = Object.freeze(consoleOwners.map((o) => Object.freeze({ provider: o.provider, iss: o.iss, sub: o.sub })));
|
||||
if (access !== null && (typeof access?.file !== 'string' || access.context === null || typeof access.context !== 'object'))
|
||||
throw new BusError('invalid-host-request');
|
||||
if (librarySync !== null && (typeof librarySync?.command !== 'function'
|
||||
@@ -281,6 +305,8 @@ export async function startBroker({
|
||||
store,
|
||||
businesses,
|
||||
library: {
|
||||
lookup: createLibraryLookup({ dataRoot, sync: context =>
|
||||
processing.work(() => libraryModule.sync(context, syncOptions(context.business))) }),
|
||||
readFile: context => libraryModule.readLibraryFile({ ...context, dataRoot }),
|
||||
status: context => libraryModule.libraryStatus({ ...context, dataRoot }),
|
||||
sync: (context, { accept }) => processing.work(() => libraryModule.sync({ ...context, dataRoot }, { ...syncOptions(context.business), accept })),
|
||||
@@ -396,7 +422,6 @@ export async function startBroker({
|
||||
tester: fileURLToPath(new URL('./tester-cli.mjs', import.meta.url)),
|
||||
user: fileURLToPath(new URL('./user-cli.mjs', import.meta.url)),
|
||||
console: fileURLToPath(new URL('../../webui/src/cli.mjs', import.meta.url)),
|
||||
consoleCli: fileURLToPath(new URL('./console-cli.mjs', import.meta.url)),
|
||||
};
|
||||
const path = join(store.directory, 'broker.sock');
|
||||
// The existing host executor owns SSH and its key lifecycle. Combine its revocation
|
||||
@@ -428,11 +453,6 @@ export async function startBroker({
|
||||
const p = verifyPrincipal({ user: proof }, { peer, paths: principalPaths, businesses, launches: launchRecords });
|
||||
return broker.bindUser({ business: p.business, user: p.id });
|
||||
},
|
||||
// Row 252 (#1753): a fresh proof per connection, a cap for the Console verbs only.
|
||||
authenticateConsole: (proof, peer) => {
|
||||
const p = verifyPrincipal({ console: proof }, { peer, paths: principalPaths, businesses, launches: launchRecords, consoleOwners: owners });
|
||||
return broker.bindConsole({ business: p.business, human: p.id, subject: p.subject });
|
||||
},
|
||||
});
|
||||
// Row 235 (r6 X3): the remote undo of interrupted host grants, once the bus is up, so a start doesn't
|
||||
// wait on the network.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createServer } from 'node:net';
|
||||
import { lstatSync, chmodSync, unlinkSync } from 'node:fs';
|
||||
import { BusError, TASK_VERBS, WEB_VERBS, HOST_VERBS, HOSTS_VERBS, GRANTS_VERBS, PROJECT_FILE_VERBS, LIBRARY_VERBS, PROJECT_SAVE_VERB, PROJECT_SAVE_REQUEST_MAX, CONSOLE_VERBS } from './broker.mjs';
|
||||
import { BusError, TASK_VERBS, WEB_VERBS, HOST_VERBS, HOSTS_VERBS, GRANTS_VERBS, PROJECT_FILE_VERBS, LIBRARY_VERBS, PROJECT_SAVE_VERB, PROJECT_SAVE_REQUEST_MAX } from './broker.mjs';
|
||||
import { inspectInterpreter } from './interpreter.mjs';
|
||||
import { createPeerReader } from './peercred.mjs';
|
||||
const LIMIT = 65536;
|
||||
@@ -13,9 +13,8 @@ const SAVE_VERB_AT_START = new RegExp(`"verb":"${PROJECT_SAVE_VERB.replaceAll('.
|
||||
// the web verbs reach it, and without one they refuse web-not-configured (or web-not-granted).
|
||||
// `host` (row 210) is the trusted host.run executor (host-run.mjs), or null: host.run then refuses
|
||||
// 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 }) {
|
||||
// A request carries exactly one of cap, human (row S4) and tester (row 110) proof.
|
||||
export async function serve({ broker, path, authenticateHuman = null, authenticateTester = null, authenticateUser = null, peerReader = null, timeout = 5000, tasks = null, web = null, host = null }) {
|
||||
try {
|
||||
lstatSync(path);
|
||||
throw new BusError('socket-exists');
|
||||
@@ -65,17 +64,15 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
|
||||
!r ||
|
||||
Array.isArray(r) ||
|
||||
typeof r !== 'object' ||
|
||||
Object.keys(r).some((k) => !['cap', 'human', 'tester', 'user', 'console', 'verb', 'args'].includes(k)) ||
|
||||
Object.keys(r).some((k) => !['cap', 'human', 'tester', 'user', 'verb', 'args'].includes(k)) ||
|
||||
typeof r.verb !== 'string' ||
|
||||
[r.cap, r.human, r.tester, r.user, r.console].filter(Boolean).length !== 1
|
||||
[r.cap, r.human, r.tester, r.user].filter(Boolean).length !== 1
|
||||
)
|
||||
throw new BusError('invalid-envelope');
|
||||
let cap = r.cap;
|
||||
const kind = r.human ? 'human' : r.tester ? 'tester' : r.user ? 'user' : r.console ? 'console' : null;
|
||||
// Row 252 (#1753): a Console owner proof reaches its two verbs only, refused before any proof check.
|
||||
if (kind === 'console' && !CONSOLE_VERBS.includes(r.verb)) throw new BusError('human-required');
|
||||
const kind = r.human ? 'human' : r.tester ? 'tester' : r.user ? 'user' : null;
|
||||
if (kind) {
|
||||
const authenticate = kind === 'human' ? authenticateHuman : kind === 'tester' ? authenticateTester : kind === 'user' ? authenticateUser : authenticateConsole;
|
||||
const authenticate = kind === 'human' ? authenticateHuman : kind === 'tester' ? authenticateTester : authenticateUser;
|
||||
if (!authenticate) throw new BusError(kind === 'tester' ? 'tester-required' : 'human-required');
|
||||
socket.pause();
|
||||
let peer;
|
||||
|
||||
@@ -6,9 +6,6 @@
|
||||
// bus.mjs for six library-* STATUS entries; no proof or transport change.
|
||||
// Row 236 (#1742) re-pins human-cli.mjs for the same 130 s wait on the grants verbs; no proof or
|
||||
// transport change.
|
||||
// Row 252 (#1753, Webby N5) re-pins human.mjs, whose readProcess also keeps the five
|
||||
// MOSAIC_BUS_CONSOLE_* keys for the Console wrapper's proof, and webui bus.mjs for consoleCall,
|
||||
// which shares humanCall's spawn as transport(). The human proof and its spawn are unchanged.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
@@ -18,7 +15,7 @@ import { provedRuntime } from "../../cli/tests/channel-fixture.mjs";
|
||||
import { Client } from "../src/client.mjs";
|
||||
|
||||
const baselines = {
|
||||
"packages/bus/src/human.mjs": "bb13c146717cdc46d75d157177e400edac4d3e10f14fe511027a25fd44cec885",
|
||||
"packages/bus/src/human.mjs": "0c6d96abe4607cba0b02b0638a2fc217ff8135bac9a47761a0ef015fbe9d3079",
|
||||
"packages/bus/src/human-cli.mjs": "271b9781d73cf31cc562a56180e3ebcd725b5fde148a83347a201fdef2fc28af",
|
||||
"packages/cli/src/transport.mjs": "5129c44b4d9f642e825344df65c9bfb4bac4fc8d6b25343b42d39853dbd40774",
|
||||
// Row 165 (#1662): seven STATUS entries for the role instance routes (organization-instance-exists,
|
||||
@@ -28,7 +25,7 @@ const baselines = {
|
||||
// Row 171 (#1668): project-name-is-repo maps to 409; no proof or transport change.
|
||||
// Row 194 (#1692, brief item 7, sage-1610 a): organization-tracker-required 400 and
|
||||
// organization-tracker-bot-taken 409; no proof or transport change.
|
||||
"packages/webui/src/bus.mjs": "701f009e488a261558a22d19f581b052ffd26ce99266f2022dc5d178c12f5272",
|
||||
"packages/webui/src/bus.mjs": "15e8123ffdbb6d067e8a516f5823efdb5de3c869963764f27cd14bb9e4ce3313",
|
||||
};
|
||||
const repoFile = path => new URL("../../../" + path, import.meta.url);
|
||||
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
// Row 252 (#1753, Gander's design approved by Webby at 5d8f0f4b5, N1-N5): the broker side of the
|
||||
// Console's Approve and Deny for a grant request. A console cap, made by bindConsole after the
|
||||
// transport checked the wrapper and the signed-in owner, reaches only decision.preview.console and
|
||||
// decision.resolve.console, and no other cap reaches those. The answer records via 'console' and a
|
||||
// human.input that names the owner, written before the grant check (N2), and only that record lets
|
||||
// an approve start the executor (J13, J14). Q188: no agent caller answers. The grant uses row 234's
|
||||
// host-enroll raise with a fake keyscan and fake enroll and check steps, so nothing connects anywhere.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Broker, CONSOLE_VERBS } from '../src/broker.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { hostGrant, fingerprint } from '../src/host-grant.mjs';
|
||||
import { grantDecision } from './host-fixtures.mjs';
|
||||
|
||||
const options = [{ key: 'yes', text: 'Yes, enroll it' }, { key: 'no', text: 'No' }];
|
||||
const SUBJECT = { provider: 'fake-oidc', iss: 'https://idp.test', sub: 'owner-1' };
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const keyLine = (dir, name) => {
|
||||
execFileSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', 'x', '-f', join(dir, name)]);
|
||||
return readFileSync(join(dir, name + '.pub'), 'utf8').trim().split(' ').slice(0, 2).join(' ');
|
||||
};
|
||||
const refusesSync = (fn, code) => assert.throws(fn, (e) => (assert.equal(e.code, code), true));
|
||||
|
||||
function setup(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'c252-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => {
|
||||
store.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const hostKeys = [keyLine(root, 'hk1'), keyLine(root, 'hk2')];
|
||||
const configDir = join(root, 'config');
|
||||
const real = hostGrant({ configDir, runRoot: join(configDir, 'secrets', 'run'), scan: async () => hostKeys });
|
||||
const enrolls = [];
|
||||
const hg = {
|
||||
...real,
|
||||
enroll: async (...args) => (enrolls.push(args), real.enroll(...args)),
|
||||
check: async (...args) => ({ installerFingerprint: args[2].installer_fingerprint, exit: 0 }),
|
||||
};
|
||||
const business = {
|
||||
id: 'demo',
|
||||
human: 'jason',
|
||||
arbiters: { technical: 'pm', delivery: 'pm' },
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
},
|
||||
vars: { 'bus.tester': true },
|
||||
};
|
||||
const make = () => new Broker({ store, businesses: { demo: business }, hostGrant: hg });
|
||||
const broker = make();
|
||||
const caps = {};
|
||||
for (const role of ['chief', 'pm']) {
|
||||
caps[role] = broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi', address: role });
|
||||
broker.request(caps[role], { verb: 'role.claim' });
|
||||
}
|
||||
caps.human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
caps.tester = broker.bindTester({ business: 'demo' });
|
||||
caps.reader = broker.bindReader({ business: 'demo' });
|
||||
caps.console = broker.bindConsole({ business: 'demo', human: 'jason', subject: SUBJECT });
|
||||
const grant = { method: 'host-enroll', host: 'web1', address: '192.0.2.10', port: 22, user: 'jarvis', sudo: true, fingerprints: hostKeys.map(fingerprint) };
|
||||
const call = (who, verb, args = {}) => broker.request(caps[who], { verb, args });
|
||||
const raise = (extra = {}) =>
|
||||
call('chief', 'decision.raise', { action: 'credential.mint', question: 'Enroll web1?', options, recommendation: 'yes', blocking: false, grant, ...extra });
|
||||
const events = () =>
|
||||
store
|
||||
.all("SELECT seq, kind, actor_role, actor_run, subject, body FROM events WHERE business='demo' ORDER BY seq")
|
||||
// The row's subject is `about`, since a console answer's body has its own `subject`.
|
||||
.map((e) => ({ seq: e.seq, kind: e.kind, actor_role: e.actor_role, actor_run: e.actor_run, about: e.subject, ...JSON.parse(e.body) }));
|
||||
const grantEvents = (decision) => events().filter((e) => e.actor_run === 'grant' && (e.decision === decision || e.grantDecision === decision));
|
||||
const settle = async (decision) => {
|
||||
for (let i = 0; i < 250; i++) {
|
||||
if (grantEvents(decision).some((e) => ['grant.host.enrolled', 'grant.failed'].includes(e.action))) return;
|
||||
await sleep(20);
|
||||
}
|
||||
throw new Error('enrollment did not end');
|
||||
};
|
||||
const closes = (decision) => store.all('SELECT seq, op, by, choice, via FROM decision_events WHERE decision=? ORDER BY seq', decision);
|
||||
return { store, broker, make, caps, call, raise, events, grantEvents, settle, closes, enrolls, grant };
|
||||
}
|
||||
|
||||
test('N4: the console verbs are exactly the preview and the console resolve, and decision.resolve is not one', () => {
|
||||
assert.deepEqual([...CONSOLE_VERBS], ['decision.preview.console', 'decision.resolve.console']);
|
||||
assert.equal(CONSOLE_VERBS.includes('decision.resolve'), false);
|
||||
assert.ok(Object.isFrozen(CONSOLE_VERBS));
|
||||
});
|
||||
|
||||
test('bindConsole takes only the business human and a three-string owner subject', (t) => {
|
||||
const f = setup(t);
|
||||
const bind = (record) => () => f.broker.bindConsole(record);
|
||||
refusesSync(bind({ business: 'demo', human: 'someone', subject: SUBJECT }), 'human-required');
|
||||
refusesSync(bind({ business: 'demo', human: 'jason', subject: { ...SUBJECT, name: 'Jason' } }), 'human-required');
|
||||
refusesSync(bind({ business: 'demo', human: 'jason', subject: { provider: 'p', iss: 'i' } }), 'human-required');
|
||||
refusesSync(bind({ business: 'demo', human: 'jason', subject: { ...SUBJECT, sub: '' } }), 'human-required');
|
||||
refusesSync(bind({ business: 'demo', human: 'jason', subject: { ...SUBJECT, sub: 'x'.repeat(513) } }), 'human-required');
|
||||
refusesSync(bind({ business: 'demo', human: 'jason', subject: { ...SUBJECT, sub: 7 } }), 'human-required');
|
||||
refusesSync(bind({ business: 'demo', human: 'jason', subject: 'owner-1' }), 'human-required');
|
||||
assert.throws(bind({ business: 'demo', human: 'jason', subject: SUBJECT, via: 'cli' }));
|
||||
assert.throws(bind({ business: 'demo', human: 'jason' }));
|
||||
assert.throws(bind({ business: 'other', human: 'jason', subject: SUBJECT }));
|
||||
assert.deepEqual(f.broker.identity(f.caps.console), { business: 'demo', role: null, run: null, human: 'jason' });
|
||||
});
|
||||
|
||||
test('J1, Q188: only a console cap reaches the console verbs, and a console cap reaches nothing else', async (t) => {
|
||||
const f = setup(t);
|
||||
const d = await f.raise();
|
||||
const preview = f.call('console', 'decision.preview.console', { id: d.id });
|
||||
const args = { id: d.id, choice: 'yes', digest: preview.digest };
|
||||
// Agents, the cli human, the tester and the reader are refused before the verb runs.
|
||||
for (const who of ['chief', 'pm', 'human', 'tester', 'reader']) {
|
||||
refusesSync(() => f.call(who, 'decision.preview.console', { id: d.id }), 'human-required');
|
||||
refusesSync(() => f.call(who, 'decision.resolve.console', args), 'human-required');
|
||||
}
|
||||
// N4: the console cap answers nothing through decision.resolve, and reaches no other verb.
|
||||
for (const [verb, a] of [
|
||||
['decision.resolve', { id: d.id, choice: 'yes' }],
|
||||
['decision.seen', { id: d.id }],
|
||||
['decision.withdraw', { id: d.id }],
|
||||
['decision.raise', { action: 'credential.mint', question: 'q', options, recommendation: 'yes', blocking: false, grant: f.grant }],
|
||||
['inbox', {}],
|
||||
['message.send', { to: 'chief', body: 'hi' }],
|
||||
['role.claim', {}],
|
||||
])
|
||||
refusesSync(() => f.call('console', verb, a), 'human-required');
|
||||
assert.deepEqual(f.closes(d.id), []);
|
||||
assert.equal(f.enrolls.length, 0);
|
||||
});
|
||||
|
||||
test('preview: the two real answers, a grant summary and the digest, for an open grant request only', async (t) => {
|
||||
const f = setup(t);
|
||||
const d = await f.raise();
|
||||
const p = f.call('console', 'decision.preview.console', { id: d.id });
|
||||
assert.equal(p.id, d.id);
|
||||
assert.equal(p.business, 'demo');
|
||||
assert.match(p.question, /^Enroll web1\?/);
|
||||
assert.deepEqual(p.approve, { key: 'yes', text: 'Yes, enroll it' });
|
||||
assert.deepEqual(p.deny, { key: 'no', text: 'No' });
|
||||
assert.deepEqual(p.grant, { method: 'host-enroll', host: 'web1' });
|
||||
assert.match(p.digest, /^[a-f0-9]{64}$/);
|
||||
// Read twice, the digest is the same; a preview writes nothing.
|
||||
assert.equal(f.call('console', 'decision.preview.console', { id: d.id }).digest, p.digest);
|
||||
assert.deepEqual(f.closes(d.id), []);
|
||||
refusesSync(() => f.call('console', 'decision.preview.console', { id: d.id, choice: 'yes' }), 'invalid-request');
|
||||
refusesSync(() => f.call('console', 'decision.preview.console', { id: 'no-such-decision' }), 'decision-not-found');
|
||||
// The inbox row says the same, for the human and the reader. The agent-facing views do not.
|
||||
for (const who of ['human', 'reader']) {
|
||||
const row = f.call(who, 'inbox').find((r) => r.id === d.id);
|
||||
assert.deepEqual(row.console, { approve: p.approve, deny: p.deny, grant: p.grant, digest: p.digest });
|
||||
}
|
||||
});
|
||||
|
||||
test('J1, J2: a decision that is not an eligible grant request refuses both console verbs', async (t) => {
|
||||
const f = setup(t);
|
||||
const grant = { method: 'host', agent: 'pm', host: 'web1', commands: ['uptime'], rotateBy: '2099-01-01' };
|
||||
const ineligible = [
|
||||
grantDecision(f.store, { action: 'deploy', grant, close: null }),
|
||||
grantDecision(f.store, { route: 'pm', grant, close: null }),
|
||||
grantDecision(f.store, { grant: undefined, close: null }),
|
||||
];
|
||||
for (const id of ineligible) {
|
||||
refusesSync(() => f.call('console', 'decision.preview.console', { id }), 'decision-not-eligible');
|
||||
refusesSync(() => f.call('console', 'decision.resolve.console', { id, choice: 'yes', digest: '0'.repeat(64) }), 'decision-not-eligible');
|
||||
assert.equal(f.call('human', 'inbox').find((r) => r.id === id)?.console, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test('N2, J13: a Console approve records via console and the owner, before the executor starts, and the grant runs once', async (t) => {
|
||||
const f = setup(t);
|
||||
const d = await f.raise();
|
||||
const { digest } = f.call('console', 'decision.preview.console', { id: d.id });
|
||||
const r = f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes', digest });
|
||||
assert.deepEqual(r, { id: d.id, op: 'resolved', choice: 'yes', grant: 'started' });
|
||||
const [close] = f.closes(d.id);
|
||||
assert.deepEqual({ ...close, seq: undefined }, { seq: undefined, op: 'resolved', by: 'jason', choice: 'yes', via: 'console' });
|
||||
const input = f.events().filter((e) => e.kind === 'human.input' && e.about === d.id);
|
||||
assert.equal(input.length, 1);
|
||||
assert.deepEqual(
|
||||
{ ...input[0], seq: undefined },
|
||||
{ seq: undefined, kind: 'answer', actor_role: null, actor_run: null, about: d.id, decision: d.id, op: 'resolved', choice: 'yes', via: 'console', subject: SUBJECT, decisionSeq: close.seq },
|
||||
);
|
||||
await f.settle(d.id);
|
||||
assert.equal(f.enrolls.length, 1);
|
||||
const spend = f.grantEvents(d.id).find((e) => e.operation === 'grant.host-enroll');
|
||||
assert.ok(spend && input[0].seq < spend.seq, 'the human.input precedes the executor spend');
|
||||
assert.ok(f.grantEvents(d.id).some((e) => e.action === 'grant.host.enrolled'));
|
||||
// N3: the close notice to the raiser comes from the human through the Console.
|
||||
const notices = f.store.all("SELECT from_run, body FROM messages WHERE business='demo' AND decision=? AND class='DECISION' AND from_role='human'", d.id);
|
||||
assert.equal(notices.length, 1);
|
||||
const [notice] = notices;
|
||||
assert.equal(notice.from_run, 'human-console');
|
||||
assert.match(notice.body, /resolved by jason: yes \(Yes, enroll it\)/);
|
||||
// A second answer of either kind finds it closed, and nothing starts again.
|
||||
refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes', digest }), 'decision-closed');
|
||||
refusesSync(() => f.call('console', 'decision.preview.console', { id: d.id }), 'decision-closed');
|
||||
refusesSync(() => f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' }), 'decision-closed');
|
||||
await sleep(50);
|
||||
assert.equal(f.enrolls.length, 1);
|
||||
assert.equal(f.closes(d.id).length, 1);
|
||||
});
|
||||
|
||||
test('J15: a Console deny closes with the other key and starts nothing', async (t) => {
|
||||
const f = setup(t);
|
||||
const d = await f.raise();
|
||||
const { digest } = f.call('console', 'decision.preview.console', { id: d.id });
|
||||
const r = f.call('console', 'decision.resolve.console', { id: d.id, choice: 'no', digest });
|
||||
assert.deepEqual(r, { id: d.id, op: 'resolved', choice: 'no' });
|
||||
assert.deepEqual(f.closes(d.id).map((c) => [c.op, c.choice, c.via]), [['resolved', 'no', 'console']]);
|
||||
await sleep(100);
|
||||
assert.equal(f.enrolls.length, 0);
|
||||
assert.deepEqual(f.grantEvents(d.id), []);
|
||||
assert.deepEqual(f.make().reconcileGrants(), []);
|
||||
});
|
||||
|
||||
test('J2: approve and deny come from approvalChoice and the options, not a fixed yes and no', async (t) => {
|
||||
const f = setup(t);
|
||||
// Deny listed first, so a build that takes options[0] as approve fails too.
|
||||
const keyed = { options: [{ key: 'stop', text: 'Stop' }, { key: 'go', text: 'Go ahead' }], approvalChoice: 'go', recommendation: 'go' };
|
||||
const denied = await f.raise({ ...keyed, question: 'Enroll web1? (deny)' });
|
||||
const p = f.call('console', 'decision.preview.console', { id: denied.id });
|
||||
assert.deepEqual([p.approve, p.deny], [{ key: 'go', text: 'Go ahead' }, { key: 'stop', text: 'Stop' }]);
|
||||
for (const choice of ['yes', 'no']) refusesSync(() => f.call('console', 'decision.resolve.console', { id: denied.id, choice, digest: p.digest }), 'decision-stale');
|
||||
assert.deepEqual(f.call('console', 'decision.resolve.console', { id: denied.id, choice: 'stop', digest: p.digest }), { id: denied.id, op: 'resolved', choice: 'stop' });
|
||||
await sleep(100);
|
||||
assert.equal(f.enrolls.length, 0);
|
||||
const approved = await f.raise({ ...keyed, question: 'Enroll web1? (approve)' });
|
||||
const { digest } = f.call('console', 'decision.preview.console', { id: approved.id });
|
||||
assert.deepEqual(f.call('console', 'decision.resolve.console', { id: approved.id, choice: 'go', digest }), { id: approved.id, op: 'resolved', choice: 'go', grant: 'started' });
|
||||
await f.settle(approved.id);
|
||||
assert.equal(f.enrolls.length, 1);
|
||||
});
|
||||
|
||||
test('J11: a stale digest or a key outside the two refuses decision-stale and writes nothing', async (t) => {
|
||||
const f = setup(t);
|
||||
const d = await f.raise();
|
||||
const { digest } = f.call('console', 'decision.preview.console', { id: d.id });
|
||||
refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes', digest: 'f'.repeat(64) }), 'decision-stale');
|
||||
refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'maybe', digest }), 'decision-stale');
|
||||
refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes' }), 'invalid-request');
|
||||
refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes', digest, note: 'x' }), 'invalid-request');
|
||||
// Another decision's digest does not answer this one.
|
||||
const other = await f.raise({ question: 'Enroll web1 again?' });
|
||||
const od = f.call('console', 'decision.preview.console', { id: other.id }).digest;
|
||||
assert.notEqual(od, digest);
|
||||
refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes', digest: od }), 'decision-stale');
|
||||
assert.deepEqual(f.closes(d.id), []);
|
||||
assert.equal(f.events().filter((e) => e.kind === 'human.input').length, 0);
|
||||
// A refusal is recorded, as for every verb.
|
||||
assert.ok(f.events().some((e) => e.kind === 'action.refused' && e.code === 'decision-stale'));
|
||||
// The decision closed by the cli meanwhile: the console answer finds it closed.
|
||||
f.call('human', 'decision.resolve', { id: d.id, choice: 'no' });
|
||||
refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes', digest }), 'decision-closed');
|
||||
});
|
||||
|
||||
test('J14: a via console close with no matching human.input approves nothing', (t) => {
|
||||
const f = setup(t);
|
||||
const grant = { method: 'host-enroll', host: 'web2', address: '192.0.2.11', port: 22, user: 'jarvis', sudo: true, fingerprints: [] };
|
||||
const cli = grantDecision(f.store, { grant });
|
||||
const bare = grantDecision(f.store, { grant: { ...grant, host: 'web3' }, close: { op: 'resolved', by: 'jason', choice: 'yes', via: 'console' } });
|
||||
const wrong = grantDecision(f.store, { grant: { ...grant, host: 'web4' }, close: { op: 'resolved', by: 'jason', choice: 'yes', via: 'console' } });
|
||||
const good = grantDecision(f.store, { grant: { ...grant, host: 'web5' }, close: { op: 'resolved', by: 'jason', choice: 'yes', via: 'console' } });
|
||||
const seqOf = (id) => f.store.get('SELECT seq FROM decision_events WHERE decision=?', id).seq;
|
||||
const input = (id, body, role = null) =>
|
||||
f.store.run(
|
||||
"INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,'human.input',?,?,?,?)",
|
||||
'e-' + id + '-' + Math.random(), '2026-10-22T00:00:00.000Z', 'demo', role, role && role + '-run', id, JSON.stringify(body),
|
||||
);
|
||||
const answer = (id) => ({ kind: 'answer', decision: id, op: 'resolved', choice: 'yes', via: 'console', subject: SUBJECT, decisionSeq: seqOf(id) });
|
||||
// `bare` has no human.input at all. `wrong` has records that each break one part: another seq, choice, empty subject, an agent actor.
|
||||
input(wrong, { ...answer(wrong), decisionSeq: seqOf(wrong) + 1000 });
|
||||
input(wrong, { ...answer(wrong), choice: 'no' });
|
||||
input(wrong, { ...answer(wrong), subject: { ...SUBJECT, sub: '' } });
|
||||
input(wrong, answer(wrong), 'chief');
|
||||
input(good, answer(good));
|
||||
// reconcileGrants ends every approved, unspent host-enroll: exactly the cli one and the good console one.
|
||||
assert.deepEqual(f.make().reconcileGrants().sort(), [cli, good].sort());
|
||||
assert.equal(f.enrolls.length, 0);
|
||||
assert.equal(f.store.get("SELECT count(*) AS n FROM events WHERE business='demo' AND actor_run='grant' AND json_extract(body,'$.decision') IN (?,?)", bare, wrong).n, 0);
|
||||
});
|
||||
|
||||
test('recordEvent keeps human.input reserved, so no agent forges a console answer', async (t) => {
|
||||
const f = setup(t);
|
||||
const d = await f.raise();
|
||||
const forged = { kind: 'human.input', subject: d.id, body: { kind: 'answer', decision: d.id, op: 'resolved', choice: 'yes', via: 'console', subject: SUBJECT, decisionSeq: 1 } };
|
||||
for (const who of ['chief', 'pm']) refusesSync(() => f.broker.recordEvent(f.caps[who], forged), 'reserved-event');
|
||||
refusesSync(() => f.broker.recordEvent(f.caps.console, forged), 'agent-required');
|
||||
assert.equal(f.events().filter((e) => e.kind === 'human.input').length, 0);
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
// Row 224: exercise broker authorization, real C2-05a queries and host served snapshots.
|
||||
// All bodies and remotes are made up. External sync is replaced only at its trusted host seam.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Broker } from '../src/broker.mjs';
|
||||
import { createLibraryLookup, startBroker } from '../src/runtime.mjs';
|
||||
import { serve } from '../src/server.mjs';
|
||||
import { Client } from '../src/client.mjs';
|
||||
import * as library from '../../library/src/index.mjs';
|
||||
import { scratchRepo, note } from '../../library/tests/query-fixture.mjs';
|
||||
import { mappingEntry, git } from '../../library/tests/helpers.mjs';
|
||||
|
||||
const NOW = Date.parse('2026-10-22T16:00:00.000Z');
|
||||
const VERBS = ['library.find', 'library.get', 'library.search', 'library.manuscript', 'library.universe'];
|
||||
const GET = { universe: 'harbor', path: 'Book One/Characters/Tomas.md' };
|
||||
const ARGS = [
|
||||
{ universe: 'harbor', query: 'Tomas' }, GET,
|
||||
{ universe: 'harbor', text: 'ferry' }, { universe: 'harbor', book: 'Book One' }, { universe: 'harbor' },
|
||||
];
|
||||
const TEXTS = {
|
||||
'Book One/Characters/Tomas.md': note('character', 'Tomas', 'He runs the ferry. [[Elsewhere]]'),
|
||||
'Book One/Chapters/01.md': '# A ferry\nOne line.\n',
|
||||
'Book One/Playground/What If.md': note('character', 'Maybe Tomas', 'Unwritten ferry.'),
|
||||
'Second World/Characters/Elsewhere.md': note('character', 'Elsewhere', 'Other ferry.'),
|
||||
};
|
||||
|
||||
async function world(t, { projects, adapter, claimed = true, now = () => NOW } = {}) {
|
||||
const f = scratchRepo(t, TEXTS), dataRoot = join(f.root, 'data');
|
||||
mkdirSync(dataRoot);
|
||||
const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true },
|
||||
projects: projects ?? { books: mappingEntry() }, arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
|
||||
const context = { business, project: 'books', dataRoot };
|
||||
let mirror = null, state = null, fetches = 0;
|
||||
if (business.projects.books?.kind === 'library') {
|
||||
mirror = await library.ensureMirror(context);
|
||||
git(f.repo, '-c', 'protocol.file.allow=always', 'push', '-q', mirror, 'HEAD:refs/heads/served');
|
||||
state = { version: 1, branch: 'main', served: f.commit, lastFetch: new Date(NOW).toISOString(),
|
||||
lastError: null, diverged: null, problems: [] };
|
||||
writeFileSync(join(mirror, library.SYNC_STATE_FILE), JSON.stringify(state), { mode: 0o600 });
|
||||
}
|
||||
const setState = patch => {
|
||||
state = { ...state, ...patch };
|
||||
writeFileSync(join(mirror, library.SYNC_STATE_FILE), JSON.stringify(state), { mode: 0o600 });
|
||||
};
|
||||
const lookup = createLibraryLookup({ dataRoot, now, sync: async () => {
|
||||
fetches++;
|
||||
setState({ lastFetch: new Date(now()).toISOString() });
|
||||
return { state, code: null };
|
||||
} });
|
||||
const store = new Store(dataRoot);
|
||||
t.after(() => store.close());
|
||||
const broker = new Broker({ store, businesses: { acme: business },
|
||||
library: adapter ?? { lookup, LibraryError: library.LibraryError } });
|
||||
const agent = broker.bindLaunch({ business: 'acme', role: 'coder', run: 'r224', harness: 'pi' });
|
||||
if (claimed) broker.request(agent, { verb: 'role.claim' });
|
||||
const human = broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const reader = broker.bindReader({ business: 'acme' }), tester = broker.bindTester({ business: 'acme' });
|
||||
const call = (verb, args = {}, cap = agent) => Promise.resolve().then(() => broker.request(cap, { verb, args }));
|
||||
return { ...f, dataRoot, business, context, mirror, store, broker, agent, human, reader, tester,
|
||||
call, lookup, setState, fetches: () => fetches };
|
||||
}
|
||||
|
||||
test('all five agent verbs read the configured served commit through real queries', async t => {
|
||||
const f = await world(t);
|
||||
const [find, get, search, manuscript, universe] = await Promise.all(VERBS.map((verb, i) => f.call(verb, ARGS[i])));
|
||||
for (const result of [find, get, search, manuscript, universe]) assert.equal(result.commit, f.commit);
|
||||
assert.deepEqual(find.results.map(x => x.path), ['Book One/Characters/Tomas.md']);
|
||||
assert.match(JSON.stringify(get.lines), /He runs the ferry/);
|
||||
assert.deepEqual(get.links_out, []); // The only target is in another universe.
|
||||
assert.ok(search.results.length > 0);
|
||||
assert.ok(search.results.every(x => !x.path.startsWith('Second World/') && x.scope === 'canon'));
|
||||
assert.equal(manuscript.path, 'Book One/Chapters/01.md');
|
||||
assert.deepEqual(universe.books, ['Book One']);
|
||||
assert.equal(f.fetches(), 0);
|
||||
const playground = await f.call('library.find', { universe: 'harbor', query: '', scope: 'playground' });
|
||||
assert.deepEqual(playground.results.map(x => x.path), ['Book One/Playground/What If.md']);
|
||||
assert.equal(await f.call('library.get', { universe: 'harbor', path: 'Second World/Characters/Elsewhere.md' }), null);
|
||||
});
|
||||
|
||||
test('each verb refuses missing universe and unknown keys before host work', async t => {
|
||||
let calls = 0;
|
||||
const f = await world(t, { adapter: { lookup: () => { calls++; } } });
|
||||
for (const [i, verb] of VERBS.entries()) {
|
||||
const { universe, ...missing } = ARGS[i];
|
||||
await assert.rejects(f.call(verb, missing), { code: 'invalid-request' });
|
||||
for (const extra of [{ repo: '/elsewhere' }, { commit: 'b'.repeat(40) }, { business: 'other' }, { accept: 'b'.repeat(40) }])
|
||||
await assert.rejects(f.call(verb, { ...ARGS[i], ...extra }), { code: 'invalid-request' });
|
||||
await assert.rejects(f.call(verb, { ...ARGS[i], universe: '' }), { code: 'invalid-request' });
|
||||
}
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('no library, missing host adapter and undeclared project refuse library-not-configured', async t => {
|
||||
const noLibrary = await world(t, { projects: {} });
|
||||
const noAdapter = await world(t, { adapter: {} });
|
||||
const f = await world(t);
|
||||
for (const [i, verb] of VERBS.entries()) {
|
||||
await assert.rejects(noLibrary.call(verb, ARGS[i]), { code: 'library-not-configured' });
|
||||
await assert.rejects(noAdapter.call(verb, ARGS[i]), { code: 'library-not-configured' });
|
||||
await assert.rejects(f.call(verb, { ...ARGS[i], project: 'other' }), { code: 'library-not-configured' });
|
||||
}
|
||||
f.business.projects.second = mappingEntry();
|
||||
await assert.rejects(f.call('library.get', GET), { code: 'invalid-request' });
|
||||
assert.equal((await f.call('library.get', { ...GET, project: 'books' })).commit, f.commit);
|
||||
});
|
||||
|
||||
test('reads need the current holder on each call; human/tester/reader are not agents', async t => {
|
||||
const f = await world(t, { claimed: false });
|
||||
for (const [i, verb] of VERBS.entries()) {
|
||||
await assert.rejects(f.call(verb, ARGS[i]), { code: 'not-holder' });
|
||||
await assert.rejects(f.call(verb, ARGS[i], f.human), { code: 'agent-required' });
|
||||
await assert.rejects(f.call(verb, ARGS[i], f.tester), { code: 'tester-refused' });
|
||||
await assert.rejects(f.call(verb, ARGS[i], f.reader), { code: 'read-only' });
|
||||
}
|
||||
f.broker.request(f.agent, { verb: 'role.claim' });
|
||||
assert.equal((await f.call('library.get', GET)).commit, f.commit);
|
||||
f.broker.request(f.agent, { verb: 'role.release' });
|
||||
await assert.rejects(f.call('library.get', GET), { code: 'not-holder' });
|
||||
});
|
||||
|
||||
test('releasing a claim during an async lookup fences the response', { timeout: 30_000 }, async t => {
|
||||
let release, entered;
|
||||
const started = new Promise(resolve => { entered = resolve; });
|
||||
const f = await world(t, { adapter: { lookup: async () => {
|
||||
entered(); await new Promise(resolve => { release = resolve; }); return { lines: [[1, 'not returned']] };
|
||||
} } });
|
||||
const pending = f.call('library.get', GET);
|
||||
await started;
|
||||
f.broker.request(f.agent, { verb: 'role.release' });
|
||||
release();
|
||||
await assert.rejects(pending, { code: 'not-holder' });
|
||||
});
|
||||
|
||||
test('a business library removed during an async read cannot leak its result', { timeout: 30_000 }, async t => {
|
||||
let release, entered;
|
||||
const started = new Promise(resolve => { entered = resolve; });
|
||||
const f = await world(t, { adapter: { lookup: async () => {
|
||||
entered(); await new Promise(resolve => { release = resolve; }); return { lines: [[1, 'not returned']] };
|
||||
} } });
|
||||
const pending = f.call('library.get', GET);
|
||||
await started;
|
||||
delete f.business.projects.books;
|
||||
release();
|
||||
await assert.rejects(pending, { code: 'library-not-configured' });
|
||||
});
|
||||
|
||||
test('freshness boundary, unknown last fetch and failed fetch still use immutable served state', async t => {
|
||||
const f = await world(t);
|
||||
f.setState({ lastFetch: new Date(NOW - 60_000).toISOString() });
|
||||
await f.call('library.get', GET);
|
||||
assert.equal(f.fetches(), 0);
|
||||
f.setState({ lastFetch: new Date(NOW - 60_001).toISOString() });
|
||||
await f.call('library.get', GET);
|
||||
assert.equal(f.fetches(), 1);
|
||||
f.setState({ lastFetch: null });
|
||||
await f.call('library.universe', { universe: 'harbor' });
|
||||
assert.equal(f.fetches(), 2);
|
||||
let attempted = 0, clock = NOW;
|
||||
const failing = createLibraryLookup({ dataRoot: f.dataRoot, now: () => clock, sync: async () => {
|
||||
attempted++;
|
||||
f.setState({ lastError: { code: 'library-fetch-failed', at: new Date(clock).toISOString() } });
|
||||
return { code: 'library-fetch-failed' };
|
||||
} });
|
||||
f.setState({ lastFetch: null });
|
||||
const result = await failing(f.context, { method: 'get', args: GET, authorize() {} });
|
||||
assert.equal(attempted, 1);
|
||||
assert.equal(result.commit, f.commit);
|
||||
for (const elapsed of [1, 59_999, 60_000]) {
|
||||
clock = NOW + elapsed;
|
||||
const retry = await failing(f.context, { method: 'get', args: GET, authorize() {} });
|
||||
assert.equal(retry.commit, f.commit, 'serve the old pin during the failure cooldown');
|
||||
assert.equal(attempted, 1, 'recent lastError prevents another sync even though lastFetch is null');
|
||||
}
|
||||
clock = NOW + 60_001;
|
||||
assert.equal((await failing(f.context, { method: 'get', args: GET, authorize() {} })).commit, f.commit);
|
||||
assert.equal(attempted, 2, 'retry only after the failed-attempt interval expires');
|
||||
});
|
||||
|
||||
test('an unreadable sync state fails closed; a busy sync keeps serving the existing pin', async t => {
|
||||
const f = await world(t);
|
||||
const lookup = createLibraryLookup({ dataRoot: f.dataRoot, now: () => NOW, sync: async () => {
|
||||
throw new library.LibraryError('library-sync-busy', 'another fetch');
|
||||
} });
|
||||
f.setState({ lastFetch: null });
|
||||
assert.equal((await lookup(f.context, { method: 'get', args: GET, authorize() {} })).commit, f.commit);
|
||||
writeFileSync(join(f.mirror, library.SYNC_STATE_FILE), '{bad json', { mode: 0o600 });
|
||||
await assert.rejects(f.call('library.get', GET), { code: 'library-state-invalid' });
|
||||
});
|
||||
|
||||
test('a stale lookup sees the commit published by its sync, not the prior served commit', async t => {
|
||||
const f = await world(t);
|
||||
writeFileSync(join(f.repo, GET.path), note('character', 'Tomas', 'Changed ferry.'));
|
||||
git(f.repo, 'add', '-A'); git(f.repo, 'commit', '-qm', 'second fixture');
|
||||
const next = git(f.repo, 'rev-parse', 'HEAD');
|
||||
const lookup = createLibraryLookup({ dataRoot: f.dataRoot, now: () => NOW, sync: async () => {
|
||||
git(f.repo, '-c', 'protocol.file.allow=always', 'push', '-q', f.mirror, 'HEAD:refs/heads/served');
|
||||
f.setState({ served: next, lastFetch: new Date(NOW).toISOString() });
|
||||
return { code: null };
|
||||
} });
|
||||
f.setState({ lastFetch: null });
|
||||
const result = await lookup(f.context, { method: 'get', args: GET, authorize() {} });
|
||||
assert.equal(result.commit, next);
|
||||
assert.match(JSON.stringify(result.lines), /Changed ferry/);
|
||||
});
|
||||
|
||||
test('real socket awaits lookup results and rechecks rejected query arguments', { timeout: 30_000 }, async t => {
|
||||
const f = await world(t);
|
||||
const path = join(f.root, 'lookup.sock');
|
||||
const server = await serve({ broker: f.broker, path }); t.after(() => server.close());
|
||||
const client = new Client({ path, cap: f.agent });
|
||||
const result = await client.call('library.get', GET);
|
||||
assert.equal(result.commit, f.commit);
|
||||
await assert.rejects(client.call('library.find', { universe: 'harbor', query: '', scope: 'anything' }), /query-argument-invalid/);
|
||||
});
|
||||
|
||||
test('runtime actually injects the lookup adapter, without exposing human library.file', { timeout: 30_000 }, async t => {
|
||||
const f = scratchRepo(t, TEXTS), dataRoot = join(f.root, 'runtime'); mkdirSync(dataRoot);
|
||||
const business = { id: 'acme', human: 'jason', projects: { books: mappingEntry() },
|
||||
arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
|
||||
const runtime = await startBroker({ dataRoot, businesses: { acme: business } }); t.after(() => runtime.close());
|
||||
const mirror = library.mirrorPath({ business, project: 'books', dataRoot });
|
||||
git(f.repo, '-c', 'protocol.file.allow=always', 'push', '-q', mirror, 'HEAD:refs/heads/served');
|
||||
writeFileSync(join(mirror, library.SYNC_STATE_FILE), JSON.stringify({ version: 1, branch: 'main', served: f.commit,
|
||||
lastFetch: new Date().toISOString(), lastError: null, diverged: null, problems: [] }), { mode: 0o600 });
|
||||
const cap = runtime.broker.bindLaunch({ business: 'acme', role: 'coder', run: 'r224-runtime', harness: 'pi' });
|
||||
runtime.broker.request(cap, { verb: 'role.claim' });
|
||||
assert.equal((await runtime.broker.request(cap, { verb: 'library.get', args: GET })).commit, f.commit);
|
||||
await assert.rejects(runtime.broker.request(cap, { verb: 'library.file', args: { project: 'books', path: GET.path } }),
|
||||
{ code: 'human-required' });
|
||||
});
|
||||
@@ -46,52 +46,3 @@ test('human leaf identity is reread after walking the parents',()=>{
|
||||
c.readProcess=pid=>{const p=read(pid);if(pid===50&&n++>0)p.startTime='101';return p;};
|
||||
assert.throws(()=>verifyPrincipal({human:proof},c),/human-required/);
|
||||
});
|
||||
|
||||
// Row 252 (#1753, design section 2, J3-J5): the Console owner. The same-tree console-cli wrapper with
|
||||
// its own nonce, a direct same-tree `serve` parent, the subject from the wrapper's spawn environment,
|
||||
// and that subject one of the configured owners (N1).
|
||||
const owner={provider:'fake-oidc',iss:'https://idp.test',sub:'owner-1'};
|
||||
function consoleContext() {
|
||||
const c=context('user');c.paths={...paths,consoleCli:'/tree/console-cli.mjs'};c.consoleOwners=[{provider:'other',iss:'https://idp.test',sub:'owner-2'},owner];
|
||||
Object.assign(c.table.get(50),{argv:['node','/tree/console-cli.mjs'],env:{MOSAIC_BUS_CONSOLE_NONCE:nonce,MOSAIC_BUS_CONSOLE_BUSINESS:'demo',MOSAIC_BUS_CONSOLE_PROVIDER:owner.provider,MOSAIC_BUS_CONSOLE_ISS:owner.iss,MOSAIC_BUS_CONSOLE_SUB:owner.sub}});
|
||||
return c;
|
||||
}
|
||||
const consoleProof={...proof,...owner};
|
||||
test('console requires its own wrapper, nonce and serve parent, and a configured owner from the spawn env',()=>{
|
||||
assert.deepEqual(verifyPrincipal({console:consoleProof},consoleContext()),{kind:'console',business:'demo',id:'owner',via:'console',subject:owner});
|
||||
// J3: the subject is the spawn's, never the body's alone, and must be a configured owner.
|
||||
for(const change of [{sub:'owner-2'},{iss:'https://evil.test'},{provider:'other'}])
|
||||
assert.throws(()=>verifyPrincipal({console:{...consoleProof,...change}},consoleContext()),/human-required/);
|
||||
const unlisted=consoleContext();unlisted.consoleOwners=[];
|
||||
assert.throws(()=>verifyPrincipal({console:consoleProof},unlisted),/human-required/);
|
||||
for(const extra of [{name:'Jason'},{human:'owner'},{user:'owner'},{sub:''},{sub:'x'.repeat(513)}])
|
||||
assert.throws(()=>verifyPrincipal({console:{...consoleProof,...extra}},consoleContext()),/human-required/);
|
||||
// J4: the parent is the same-tree serve, direct, and not an agent.
|
||||
for(const change of [{argv:['bash']},{argv:['node','/other/console.mjs','serve']},{argv:['node',paths.console,'other']},{env:{CODEX_THREAD_ID:'agent'}},{uid:uid+1}]) {
|
||||
const c=consoleContext();Object.assign(c.table.get(40),change);
|
||||
assert.throws(()=>verifyPrincipal({console:consoleProof},c),/human-required/);
|
||||
}
|
||||
const launched=consoleContext();launched.launches=[{pid:40,startTime:'90'}];
|
||||
assert.throws(()=>verifyPrincipal({console:consoleProof},launched),/human-required/);
|
||||
// J5: another nonce namespace, or the wrapper path of another kind, refuses.
|
||||
for(const env of [{MOSAIC_BUS_CLI_NONCE:nonce},{MOSAIC_BUS_USER_NONCE:nonce}]) {
|
||||
const c=consoleContext();const t=c.table.get(50);t.env={...t.env,...env};delete t.env.MOSAIC_BUS_CONSOLE_NONCE;
|
||||
assert.throws(()=>verifyPrincipal({console:consoleProof},c),/human-required/);
|
||||
}
|
||||
for(const argv of [['node',paths.human],['node',paths.user],['node','/other/console-cli.mjs']]) {
|
||||
const c=consoleContext();c.table.get(50).argv=argv;
|
||||
assert.throws(()=>verifyPrincipal({console:consoleProof},c),/human-required/);
|
||||
}
|
||||
// The Console's own nonce does not answer as the cli human or the user.
|
||||
assert.throws(()=>verifyPrincipal({human:proof},consoleContext()),/human-required/);
|
||||
assert.throws(()=>verifyPrincipal({user:{...proof,user:'owner'}},consoleContext()),/human-required/);
|
||||
// Each piece is read again after the walk: a parent or a spawn subject that changed refuses.
|
||||
for(const [pid,mutate] of [[40,p=>{p.startTime='91';}],[50,p=>{p.env={...p.env,MOSAIC_BUS_CONSOLE_SUB:'owner-2'};}],[50,p=>{p.ppid=41;}]]) {
|
||||
const c=consoleContext(),read=c.readProcess;let n=0;
|
||||
// The last read of each pid is verifyConsole's own reread: 40 is read 3 times, 50 is read 4 times.
|
||||
c.readProcess=x=>{const p=read(x);if(x===pid&&n++===(pid===40?2:3))mutate(p);return p;};
|
||||
assert.throws(()=>verifyPrincipal({console:consoleProof},c),/human-required/);
|
||||
}
|
||||
for(const peer of [{pid:51,uid,gid:uid},{pid:50,uid:uid+1,gid:uid}])assert.throws(()=>verifyPrincipal({console:consoleProof},{...consoleContext(),peer}),/human-required/);
|
||||
assert.throws(()=>verifyPrincipal({console:consoleProof,human:proof},consoleContext()),/invalid-envelope/);
|
||||
});
|
||||
@@ -83,9 +83,6 @@ function trackerFor(system, b, warn) {
|
||||
};
|
||||
}
|
||||
|
||||
export const consoleOwners = (sso) =>
|
||||
sso.providers.flatMap((p) => p.subjects.map((s) => ({ provider: p.id, iss: p.issuer, sub: s.sub })));
|
||||
|
||||
// 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.
|
||||
@@ -104,9 +101,6 @@ export function bootConfig({ system, businessId, env = process.env, warn = () =>
|
||||
};
|
||||
const tracker = trackerFor(vars, b, warn);
|
||||
if (tracker) config.trackers = { [b.id]: tracker };
|
||||
// Row 252 (#1753, Webby N1): every console.sso subject is an owner, by provider id, issuer and sub.
|
||||
// The loader checked console.sso; no new config field names owners.
|
||||
if (system.console?.sso) config.consoleOwners = consoleOwners(system.console.sso);
|
||||
return config;
|
||||
});
|
||||
}
|
||||
@@ -439,6 +439,7 @@ export function createLauncher({
|
||||
contract: readFileSync(resolved.contract, "utf8"),
|
||||
arbiter: Object.values(b.arbiters).includes(instance),
|
||||
tracker,
|
||||
library: Object.values(b.projects ?? {}).some(project => project.kind === "library"),
|
||||
credentials,
|
||||
session: { run, launchedBy, workspace, toolSocket, turnMarker, checkouts },
|
||||
harness,
|
||||
|
||||
@@ -69,19 +69,3 @@ test("an unknown business and a broken system config refuse with exit 3", (t) =>
|
||||
assert.throws(() => bootConfig({ system: loadSystem({ env: f.env }), businessId: "nope", env: f.env }), (e) => e.exitCode === 3);
|
||||
assert.throws(() => loadSystem({ env: { ...f.env, MOSAIC_CONFIG: join(f.dataRoot, "missing.json") } }), (e) => e.exitCode === 3);
|
||||
});
|
||||
|
||||
test("Row 252 (Webby N1): every console.sso subject reaches the boot as a console owner, and no sso means no consoleOwners key", (t) => {
|
||||
const f = fixture(tmp(t));
|
||||
const system = loadSystem({ env: f.env });
|
||||
assert.equal("consoleOwners" in bootConfig({ system, businessId: "acme", env: f.env }), false);
|
||||
const sso = { providers: [
|
||||
{ id: "authentik", issuer: "https://auth.example/app/", subjects: [{ sub: "jason-sub" }, { sub: "second-sub" }] },
|
||||
{ id: "other", issuer: "https://idp.example", subjects: [{ sub: "jason-sub" }] },
|
||||
] };
|
||||
const boot = bootConfig({ system: { ...system, console: { sso } }, businessId: "acme", env: f.env });
|
||||
assert.deepEqual(boot.consoleOwners, [
|
||||
{ provider: "authentik", iss: "https://auth.example/app/", sub: "jason-sub" },
|
||||
{ provider: "authentik", iss: "https://auth.example/app/", sub: "second-sub" },
|
||||
{ provider: "other", iss: "https://idp.example", sub: "jason-sub" },
|
||||
]);
|
||||
});
|
||||
@@ -27,6 +27,7 @@ import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
import { startTimeOf } from "../src/host.mjs";
|
||||
import { createLauncher } from "../src/launcher.mjs";
|
||||
import { fakeHarnesses, fixture, tmp } from "./helpers.mjs";
|
||||
import { mappingEntry } from "../../library/tests/helpers.mjs";
|
||||
// Row 264a (#1769): a thread made by hand gets the default session the 3k-3l migration gives a
|
||||
// legacy thread, with the thread's id.
|
||||
const DEFAULT_SESSION = [
|
||||
@@ -442,3 +443,24 @@ test("REQ-LIMIT-4 a thread run at its token limit stops at the end of its turn:
|
||||
assert.deepEqual(threadRuns(f, A), []);
|
||||
assert.equal(host.runtime.threadsState("acme").find((x) => x.thread === A).holder, null);
|
||||
});
|
||||
|
||||
// Row224: the launcher must derive library presence from business projects, not
|
||||
// from session checkouts (a library never has an agent clone).
|
||||
test("C2-05b a real launched bundle gets lookups for a library project without a library checkout", { skip, timeout: 60000 }, async t => {
|
||||
const prepared = prepare(t, { idle: 10, edit: doc => {
|
||||
doc.projects.books = mappingEntry();
|
||||
return doc;
|
||||
} });
|
||||
const { f } = prepared;
|
||||
const host = await start(t, prepared);
|
||||
const project = host.call("project.create", { name: "library work", repo: "stack" }).id;
|
||||
const thread = host.call("thread.create", { project, name: "lookup" }).id;
|
||||
await ask(host.call, thread, "inspect bundle");
|
||||
const run = launchLog(f).find(e => e.event === "launch" && e.thread === thread).run;
|
||||
const tools = JSON.parse(readFileSync(join(f.dataRoot, "launches", "acme", run, "bundle", "tools.json"), "utf8"));
|
||||
assert.deepEqual(tools.filter(tool => tool.name.startsWith("library_")).map(tool => tool.name),
|
||||
["library_find", "library_get", "library_search", "library_manuscript", "library_universe"]);
|
||||
const [session] = threadRuns(f, thread);
|
||||
assert.ok(session);
|
||||
assert.ok(!session.checkouts?.some(checkout => checkout.project === "books"));
|
||||
});
|
||||
@@ -292,6 +292,7 @@ function prompt({ contract, resolved, session, tools, untyped, builtins, persona
|
||||
// resolved resolveInstance(...) output
|
||||
// contract the role contract's text
|
||||
// arbiter true when the business names this instance as an arbiter
|
||||
// library true when the trusted business configuration has a library project
|
||||
// tracker true when the broker has task verbs for the business
|
||||
// credentials the broker's credentialStatus(...) for the instance (metadata only)
|
||||
// session { run, launchedBy, workspace, toolSocket, turnMarker, checkouts }
|
||||
@@ -309,11 +310,11 @@ function prompt({ contract, resolved, session, tools, untyped, builtins, persona
|
||||
// (row 213); an instance with persona.sha256 and no personaDir refuses
|
||||
// Returns { manifest, files, policy, tools, env } with absolute paths; env is the module's
|
||||
// adapter variables, which the host writes into session.json.
|
||||
export function buildBundle({ dir, resolved, contract, arbiter = false, tracker = false, credentials = [], session, harness, route = "primary", routeReason = null, harnessVersion = null, models = null, land = false, skillsDir = null, personaDir = null }) {
|
||||
export function buildBundle({ dir, resolved, contract, arbiter = false, tracker = false, library = false, credentials = [], session, harness, route = "primary", routeReason = null, harnessVersion = null, models = null, land = false, skillsDir = null, personaDir = null }) {
|
||||
if (route !== "primary" && route !== "fallback") throw new BundleError(`route ${JSON.stringify(route)} isn't primary or fallback`);
|
||||
const model = sessionModel(resolved, models, route);
|
||||
const web = webPlan(resolved, model.harness, harness.builtinWeb());
|
||||
const tools = typedTools({ resolved, arbiter, tracker, projects: (session.checkouts ?? []).map((c) => c.project), webTyped: !web.builtin, land });
|
||||
const tools = typedTools({ resolved, arbiter, tracker, library, projects: (session.checkouts ?? []).map((c) => c.project), webTyped: !web.builtin, land });
|
||||
const untyped = untypedActions(resolved, tools);
|
||||
const persona = readPersona(resolved, personaDir);
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
|
||||
@@ -78,9 +78,10 @@ const TASK_TOOLS = {
|
||||
|
||||
const MESSAGE_CLASSES = ["REQUEST", "ASSIGNMENT", "REVIEW-REQUEST", "REVIEW-RESULT", "RESULT", "INFO", "DECISION", "REACTION", "BLOCK"];
|
||||
|
||||
// typedTools({ resolved, arbiter, tracker, projects, land }) -> [{ name, verb, action, description, parameters }]
|
||||
// typedTools({ resolved, arbiter, tracker, projects, land, library }) -> [{ name, verb, action, description, parameters }]
|
||||
// resolved resolveInstance(...) output for the session's instance
|
||||
// arbiter true when the business names this instance as an arbiter
|
||||
// library true when the trusted business configuration has a library project
|
||||
// tracker true when the broker has task verbs for the business
|
||||
// projects the project ids the session has a clone of
|
||||
// land true when the role and thread hold a merge decision a landing could use (landHeld)
|
||||
@@ -88,7 +89,7 @@ const MESSAGE_CLASSES = ["REQUEST", "ASSIGNMENT", "REVIEW-REQUEST", "REVIEW-RESU
|
||||
// `verb` is the broker verb; the `launch` tool has verb "launch" and goes
|
||||
// to the host instead, and "working.push", "working.fetch" and
|
||||
// "working.land" go to the host's git socket.
|
||||
export function typedTools({ resolved, arbiter = false, tracker = false, projects = [], webTyped = true, land = false }) {
|
||||
export function typedTools({ resolved, arbiter = false, tracker = false, projects = [], webTyped = true, land = false, library = false }) {
|
||||
const held = new Set([...resolved.limits.authority.withinRole, ...resolved.limits.authority.crossRole]);
|
||||
const tools = [];
|
||||
const add = (name, verb, action, description, parameters) => tools.push({ name, verb, action, description, parameters });
|
||||
@@ -222,6 +223,32 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project
|
||||
url: str("The https url to fetch", { maxLength: 2048 }),
|
||||
}, ["url"]));
|
||||
}
|
||||
// Row224: no library clone or token reaches a session. The broker chooses the served
|
||||
// commit and requires a universe on every call; cursors page without cutting replies.
|
||||
if (library) {
|
||||
const universe = str("Universe ID in the configured library", { minLength: 1 });
|
||||
const project = str("Library project ID; omit only when the business has one library");
|
||||
const scope = str("canon by default; playground is opt-in", { enum: ["canon", "playground"] });
|
||||
const book = str("Book folder from library_universe");
|
||||
const offset = { type: "integer", minimum: 0, description: "next from the previous page" };
|
||||
const line = { type: "integer", minimum: 1, description: "One-based line number" };
|
||||
const base = { universe, project };
|
||||
add("library_find", "library.find", null, "Find names in one universe, at the served commit. At most 50 results; continue with next as offset.", obj({
|
||||
...base, query: str("Case-insensitive name or filename substring; empty matches all"),
|
||||
type: str("Mapped type", { enum: ["character", "place", "research", "tracker", "summary"] }),
|
||||
book, scope, offset,
|
||||
}, ["universe", "query"]));
|
||||
add("library_get", "library.get", null, "Read a mapped note and visible links in one universe at the served commit. next pages lines; links_next pages links. An explicit own-universe playground path is readable.", obj({
|
||||
...base, path: str("Mapped note path relative to the library"), from: line, links_from: offset,
|
||||
}, ["universe", "path"]));
|
||||
add("library_search", "library.search", null, "Search text in one universe at the served commit. At most 50 hits; use next as offset. Playground is opt-in.", obj({
|
||||
...base, text: str("Nonempty case-insensitive substring", { minLength: 1 }), book, scope, offset,
|
||||
}, ["universe", "text"]));
|
||||
add("library_manuscript", "library.manuscript", null, "Read one chapter of a book at the served commit, at most 400 lines. Omit chapter for the first; continue with next or next_chapter.", obj({
|
||||
...base, book, scope, chapter: str("Chapter filename without extension"), from: line, to: line,
|
||||
}, ["universe", "book"]));
|
||||
add("library_universe", "library.universe", null, "Read a universe's mapping at the served commit: books, types, playground prefixes, and hybrid sources/constraints/notes or draft metadata.", obj(base, ["universe"]));
|
||||
}
|
||||
add("read_spec", "spec.read", null, "Read a project's committed north-star spec and its file history. commit is an optional full reachable commit ID. The file commit, rather than HEAD, is what a decision cites. confirmed says the file commit was authored by the business's human.", obj({
|
||||
project: str("Project ID from the business file"),
|
||||
commit: str("Optional full historical commit ID", { pattern: "^[0-9a-f]{40}$" }),
|
||||
@@ -277,6 +304,11 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project
|
||||
if (held.has("task.create"))
|
||||
add("list_requirements", "requirements", null, "List the approved PRD's requirement ids, each with a one-line title. task_create takes only these. When none fits a request, raise a prd.approve decision instead of creating the task.", 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_") ||
|
||||
tool.name === "web_search" || tool.name === "web_fetch");
|
||||
return tools;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// Row224 M3: exact creative tool sets preserve the existing C2-08 web grants.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { typedTools } from '../src/tools.mjs';
|
||||
import { buildBundle } from '../src/bundle.mjs';
|
||||
import { TYPES } from '../../library/src/mapping.mjs';
|
||||
import { HARNESS, REPO_SKILLS, resolvedFor, scratch } from './helpers.mjs';
|
||||
|
||||
const LOOKUPS = ['library_find', 'library_get', 'library_search', 'library_manuscript', 'library_universe'];
|
||||
const CREATIVE = ['companion', 'research', 'ideas', 'proofreader', 'fact-checker', 'continuity'];
|
||||
const role = (t, definition, harness = 'pi') => resolvedFor(t, 'bean', d => {
|
||||
d.roles.bean = { definition, vars: { harness, model: 'claude-sonnet-5-5', thinking: 'high' } };
|
||||
});
|
||||
const expected = definition => ['send_message', ...(['companion', 'research'].includes(definition)
|
||||
? ['web_search', 'web_fetch'] : []), ...LOOKUPS];
|
||||
|
||||
test('M3: per-role snapshots contain six names, or eight 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);
|
||||
assert.ok(tools.every(t => t.parameters.additionalProperties === false));
|
||||
}
|
||||
});
|
||||
|
||||
test('every noncreative role gets lookups when a library is configured, not otherwise', t => {
|
||||
for (const instance of ['coder', 'reviewer', 'pm', 'chief']) {
|
||||
const resolved = resolvedFor(t, instance);
|
||||
const ordinary = typedTools({ resolved }).map(t => t.name);
|
||||
assert.ok(!ordinary.some(name => name.startsWith('library_')));
|
||||
const tools = typedTools({ resolved, library: true });
|
||||
assert.deepEqual(tools.filter(t => t.name.startsWith('library_')).map(t => t.name), LOOKUPS);
|
||||
assert.deepEqual(tools.filter(t => !t.name.startsWith('library_')).map(t => t.name), ordinary);
|
||||
}
|
||||
});
|
||||
|
||||
test('lookup schemas require a universe even for get, include paging, and cannot select repo or commit', t => {
|
||||
const tools = typedTools({ resolved: role(t, 'ideas'), library: true });
|
||||
const required = [ ['universe', 'query'], ['universe', 'path'], ['universe', 'text'], ['universe', 'book'], ['universe'] ];
|
||||
for (const [i, tool] of tools.filter(t => t.name.startsWith('library_')).entries()) {
|
||||
assert.deepEqual(tool.parameters.required, required[i]);
|
||||
assert.equal(tool.verb, LOOKUPS[i].replace('_', '.'));
|
||||
assert.equal(tool.action, null);
|
||||
assert.equal(tool.parameters.additionalProperties, false);
|
||||
for (const key of ['repo', 'commit', 'business', 'accept']) assert.equal(Object.hasOwn(tool.parameters.properties, key), false);
|
||||
}
|
||||
const byName = Object.fromEntries(tools.map(t => [t.name, t]));
|
||||
assert.deepEqual(byName.library_find.parameters.properties.scope.enum, ['canon', 'playground']);
|
||||
assert.deepEqual(byName.library_find.parameters.properties.type.enum, [...TYPES]);
|
||||
assert.equal(byName.library_find.parameters.properties.offset.minimum, 0);
|
||||
assert.equal(byName.library_get.parameters.properties.from.minimum, 1);
|
||||
assert.equal(byName.library_get.parameters.properties.links_from.minimum, 0);
|
||||
assert.equal(byName.library_manuscript.parameters.properties.chapter.type, 'string');
|
||||
});
|
||||
|
||||
test('bundle flag reaches both harness tool manifests and creative prompt without generic tools', t => {
|
||||
for (const definition of CREATIVE) for (const harness of ['pi', 'claude-code']) {
|
||||
const dir = scratch(t), workspace = join(dir, 'ws'); mkdirSync(workspace);
|
||||
const b = buildBundle({ dir: join(dir, 'bundle'), resolved: role(t, definition, harness), library: true,
|
||||
contract: '# Creative\n', tracker: true, arbiter: true, skillsDir: REPO_SKILLS,
|
||||
session: { run: 'r224', launchedBy: 'chief', workspace, toolSocket: join(dir, 't.sock'), turnMarker: join(dir, 'turn.done') },
|
||||
harness: HARNESS[harness] });
|
||||
assert.deepEqual(b.tools.map(t => t.name), expected(definition), `${definition} ${harness}`);
|
||||
const serialized = JSON.parse(readFileSync(b.files.tools, 'utf8'));
|
||||
assert.deepEqual(serialized.map(t => t.name), expected(definition));
|
||||
const prompt = readFileSync(b.files.prompt, 'utf8');
|
||||
assert.match(prompt, /library_find/);
|
||||
assert.doesNotMatch(prompt, /Bus tools: [^\n]*(read_spec|list_agents|inbox|trail|write_record|raise_decision|list_tasks)/);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
// The lookup index of design 3 (C2-05a): types, names, links and backlinks for one commit of her repo.
|
||||
// It is built from the reader's output, so it holds only mapped, readable files: R1, R2 and R7 leave a
|
||||
// file out, and an unresolved link is not in it. It is cached in memory by commit id. The key also
|
||||
// carries the repo path and a digest of the mapping, because one commit reads differently under another
|
||||
// mapping. The index is not universe-aware; query.mjs decides what a universe may see.
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readLibrary } from './reader.mjs';
|
||||
import { raise } from './problems.mjs';
|
||||
|
||||
const CACHE_LIMIT = 8;
|
||||
const cache = new Map();
|
||||
|
||||
const push = (map, key, value) => {
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key).push(value);
|
||||
};
|
||||
// A file's name for lookups: its front matter `name` when it is text, else its file name.
|
||||
const displayName = file => {
|
||||
const name = file.frontmatter?.name;
|
||||
return typeof name === 'string' && name.trim() ? name.trim() : file.name;
|
||||
};
|
||||
// A link as the index keeps it: the file at the other end, its line, and the field when front matter holds it.
|
||||
const linkAt = (path, link) => ({ path, line: link.line, ...(link.field === undefined ? {} : { field: link.field }) });
|
||||
|
||||
// Builds the index from readLibrary's output. Pure: no git, no clock.
|
||||
export function buildIndex(library, mapping) {
|
||||
const files = new Map(), types = new Map(), names = new Map(), linksOut = new Map(), linksIn = new Map();
|
||||
for (const file of library.files) {
|
||||
const name = displayName(file);
|
||||
files.set(file.path, {
|
||||
path: file.path, scope: file.scope, universe: file.universe, book: file.book, type: file.type,
|
||||
folder: file.folder, stem: file.name, name, frontmatter: file.frontmatter, lines: file.lines,
|
||||
});
|
||||
if (file.type !== null) push(types, file.type, file.path);
|
||||
push(names, name.toLowerCase(), file.path);
|
||||
linksOut.set(file.path, file.links.map(link => linkAt(link.path, link)));
|
||||
for (const link of file.links) push(linksIn, link.path, linkAt(file.path, link));
|
||||
}
|
||||
return Object.freeze({
|
||||
commit: library.commit, mapping, files, paths: [...files.keys()], types, names, linksOut, linksIn,
|
||||
problems: library.problems,
|
||||
});
|
||||
}
|
||||
|
||||
const digest = mapping => createHash('sha256').update(JSON.stringify(mapping)).digest('hex');
|
||||
|
||||
// The index for one commit under one mapping, read once and kept in memory. A repeat call returns the same
|
||||
// object. Past CACHE_LIMIT entries the oldest goes. A failed read is not kept.
|
||||
export async function indexFor({ repo, commit, mapping }) {
|
||||
if (!mapping || mapping.kind !== 'library') raise('library-mapping-invalid', 'expected a checked library mapping');
|
||||
const key = JSON.stringify([repo, commit, digest(mapping)]);
|
||||
if (cache.has(key)) {
|
||||
const hit = cache.get(key);
|
||||
cache.delete(key);
|
||||
cache.set(key, hit);
|
||||
return hit;
|
||||
}
|
||||
const pending = readLibrary({ repo, commit, mapping }).then(library => buildIndex(library, mapping));
|
||||
cache.set(key, pending);
|
||||
while (cache.size > CACHE_LIMIT) cache.delete(cache.keys().next().value);
|
||||
try {
|
||||
return await pending;
|
||||
} catch (error) {
|
||||
if (cache.get(key) === pending) cache.delete(key);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearIndexCache() {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
export function indexCacheSize() {
|
||||
return cache.size;
|
||||
}
|
||||
@@ -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 { buildIndex, indexFor, clearIndexCache, indexCacheSize } from './index-build.mjs';
|
||||
export { sees, find, get, search, manuscript, universeInfo, QUERY_REPLY_CAP, LOOKUP_LIMIT, MANUSCRIPT_LINES } from './query.mjs';
|
||||
@@ -0,0 +1,240 @@
|
||||
// The lookup queries of design 3 (C2-05a): find, get, search, manuscript and universeInfo. Each takes a
|
||||
// source {repo, commit, mapping} and an argument object, and returns a reply that fits QUERY_REPLY_CAP.
|
||||
// Universe isolation is here, in sees(): a file reaches a reply only when sees() allows it. Scope comes
|
||||
// from the path (D2). Unmapped files are not in the index, so they never appear. Lists page by position
|
||||
// and never cut an item. A search line too long to fit alone comes back as a marked hit with no excerpt;
|
||||
// a find item, a file's front matter or one of its lines that cannot fit alone is refused.
|
||||
import { raise } from './problems.mjs';
|
||||
import { TYPES } from './mapping.mjs';
|
||||
import { indexFor } from './index-build.mjs';
|
||||
|
||||
// The same cap as packages/harness/src/tools.mjs REPLY_CAP. The query tests assert the two stay equal.
|
||||
export const QUERY_REPLY_CAP = 40_000;
|
||||
export const LOOKUP_LIMIT = 50;
|
||||
export const MANUSCRIPT_LINES = 400;
|
||||
const SCOPES = Object.freeze(['canon', 'playground']);
|
||||
|
||||
const bytes = value => JSON.stringify(value).length;
|
||||
const invalid = message => raise('query-argument-invalid', message);
|
||||
const inside = (folder, path) => path.startsWith(folder.endsWith('/') ? folder : `${folder}/`);
|
||||
|
||||
function textArg(value, name) {
|
||||
if (typeof value !== 'string') invalid(`${name} must be text`);
|
||||
return value;
|
||||
}
|
||||
function scopeArg(value) {
|
||||
if (value === undefined) return 'canon';
|
||||
if (!SCOPES.includes(value)) invalid('scope must be "canon" or "playground"');
|
||||
return value;
|
||||
}
|
||||
function positionArg(value, name, fallback) {
|
||||
if (value === undefined) return fallback;
|
||||
if (!Number.isSafeInteger(value) || value < 1) invalid(`${name} must be a line number of 1 or more`);
|
||||
return value;
|
||||
}
|
||||
function offsetArg(value) {
|
||||
if (value === undefined) return 0;
|
||||
if (!Number.isSafeInteger(value) || value < 0) invalid('offset must be a whole number of 0 or more');
|
||||
return value;
|
||||
}
|
||||
function universeArg(mapping, value) {
|
||||
if (typeof value !== 'string' || !value) invalid('universe is required');
|
||||
if (!Object.hasOwn(mapping.universes, value)) raise('query-universe-unknown', `this library has no universe ${JSON.stringify(value)}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
// Whether universe U may see a file. Its own files, in either scope. A hybrid also sees the canon of
|
||||
// its declared sources, minus the folders its exclude constraints name. A hybrid never sees a source's
|
||||
// playground. Pin constraints do not hide anything; they are reported by universeInfo only.
|
||||
export function sees(mapping, universe, file) {
|
||||
if (file.universe === universe) return true;
|
||||
const entry = mapping.universes[universe];
|
||||
if (entry.kind !== 'hybrid' || !entry.sources.includes(file.universe) || file.scope !== 'canon') return false;
|
||||
return !entry.constraints.some(c => c.exclude !== undefined && inside(c.exclude, file.path));
|
||||
}
|
||||
|
||||
// The files one universe may see in one scope, with optional book and type filters, in path order.
|
||||
async function visible(source, universe, { scope, book, type }) {
|
||||
const index = await indexFor(source);
|
||||
const files = index.paths
|
||||
.map(path => index.files.get(path))
|
||||
.filter(file => file.scope === scope && sees(index.mapping, universe, file)
|
||||
&& (book === undefined || file.book === book) && (type === undefined || file.type === type));
|
||||
return { index, files };
|
||||
}
|
||||
|
||||
// A reply whose size is over the cap is refused, never cut.
|
||||
function checked(reply) {
|
||||
if (bytes(reply) > QUERY_REPLY_CAP) raise('query-item-too-large', `the reply is over the ${QUERY_REPLY_CAP}-character cap`);
|
||||
return reply;
|
||||
}
|
||||
|
||||
// Picks items from `offset`, at most `limit`, while the reply still fits. build(picked, next) returns
|
||||
// the reply; `next` is the position of the first item not picked, or null when none is left. An item
|
||||
// that cannot fit even alone is refused: skipping it would hide it, and cutting it would break it.
|
||||
function fitPage(items, { offset = 0, limit = LOOKUP_LIMIT, build }) {
|
||||
const picked = [];
|
||||
let at = offset;
|
||||
while (at < items.length && picked.length < limit) {
|
||||
// The size check uses items.length for `next`, the widest number a cursor can take, so a page never runs over.
|
||||
if (bytes(build([...picked, items[at]], items.length)) > QUERY_REPLY_CAP) {
|
||||
if (!picked.length) raise('query-item-too-large', `the item at position ${at} alone is over the ${QUERY_REPLY_CAP}-character cap`);
|
||||
break;
|
||||
}
|
||||
picked.push(items[at]);
|
||||
at += 1;
|
||||
}
|
||||
return checked(build(picked, at < items.length ? at : null));
|
||||
}
|
||||
|
||||
// find: the files whose name or file name holds `query`, case-insensitive. Up to 50 per page.
|
||||
export async function find(source, { universe: name, query, type, book, scope, offset } = {}) {
|
||||
const universe = universeArg(source.mapping, name);
|
||||
const needle = textArg(query, 'query').toLowerCase();
|
||||
if (type !== undefined && !TYPES.includes(type)) invalid(`type must be one of ${TYPES.join(', ')}`);
|
||||
const { index, files } = await visible(source, universe, {
|
||||
scope: scopeArg(scope), book: book === undefined ? undefined : textArg(book, 'book'), type,
|
||||
});
|
||||
const hits = files.filter(file => file.name.toLowerCase().includes(needle) || file.stem.toLowerCase().includes(needle));
|
||||
return fitPage(hits, {
|
||||
offset: offsetArg(offset),
|
||||
build: (picked, next) => ({
|
||||
commit: index.commit,
|
||||
results: picked.map(file => ({
|
||||
path: file.path, type: file.type, name: file.name, appears_in: file.frontmatter?.appears_in ?? null,
|
||||
scope: file.scope, universe: file.universe, book: file.book,
|
||||
})),
|
||||
next,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// get: one visible file, its front matter, its lines from `from`, and its visible links both ways.
|
||||
// Returns null when the file is missing or not visible, so a caller cannot tell the two apart. Lines page
|
||||
// by `from` (reply `next`), links page together by `links_from` (reply `links_next`); `links_out` come
|
||||
// before `links_in`. Every page carries the front matter. Links take at most half the cap, so a hub note
|
||||
// with many backlinks stays readable and still leaves room for lines. A page with lines left carries at
|
||||
// least one line, so links shrink when a line needs the room; read on with `from: next` and
|
||||
// `links_from: links_next`. A `from` past the last line returns links alone. Only front matter, or a
|
||||
// line that cannot fit with no links beside it, is refused.
|
||||
export async function get(source, { universe: name, path, from, links_from: linksFrom } = {}) {
|
||||
const universe = universeArg(source.mapping, name);
|
||||
const startLine = positionArg(from, 'from', 1);
|
||||
const linkStart = offsetArg(linksFrom);
|
||||
if (typeof path !== 'string') invalid('path must be text');
|
||||
const index = await indexFor(source);
|
||||
const file = index.files.get(path);
|
||||
if (!file || !sees(index.mapping, universe, file)) return null;
|
||||
const reachable = links => links.filter(link => {
|
||||
const other = index.files.get(link.path);
|
||||
return other !== undefined && sees(index.mapping, universe, other);
|
||||
});
|
||||
const outs = reachable(index.linksOut.get(file.path) ?? []);
|
||||
const ins = reachable(index.linksIn.get(file.path) ?? []);
|
||||
const links = [...outs.map(link => ({ out: true, link })), ...ins.map(link => ({ out: false, link }))];
|
||||
const lines = file.lines.slice(startLine - 1);
|
||||
const linkRest = Math.max(0, links.length - linkStart);
|
||||
const page = (nLines, nLinks) => {
|
||||
const taken = links.slice(linkStart, linkStart + nLinks);
|
||||
return {
|
||||
path: file.path, commit: index.commit, scope: file.scope, frontmatter: file.frontmatter,
|
||||
lines: lines.slice(0, nLines),
|
||||
links_out: taken.filter(item => item.out).map(item => item.link),
|
||||
links_in: taken.filter(item => !item.out).map(item => item.link),
|
||||
next: nLines < lines.length ? startLine + nLines : null,
|
||||
links_next: nLinks < linkRest ? linkStart + nLinks : null,
|
||||
};
|
||||
};
|
||||
// How many leading items fit in `room`, by each item's own size.
|
||||
const count = (items, room) => {
|
||||
let used = 0, n = 0;
|
||||
while (n < items.length && used + bytes(items[n]) + 1 <= room) used += bytes(items[n++]) + 1;
|
||||
return n;
|
||||
};
|
||||
const base = bytes(page(0, 0));
|
||||
if (base > QUERY_REPLY_CAP) raise('query-item-too-large', `the front matter of ${file.path} alone is over the ${QUERY_REPLY_CAP}-character cap`);
|
||||
if (lines.length && bytes(page(1, 0)) > QUERY_REPLY_CAP) {
|
||||
raise('query-item-too-large', `line ${lines[0][0]} of ${file.path} alone is over the ${QUERY_REPLY_CAP}-character cap`);
|
||||
}
|
||||
const rest = links.slice(linkStart);
|
||||
let nLinks = count(rest, (lines.length ? Math.floor(QUERY_REPLY_CAP / 2) : QUERY_REPLY_CAP) - base);
|
||||
if (!nLinks && rest.length) nLinks = count(rest, QUERY_REPLY_CAP - base);
|
||||
if (!nLinks && rest.length) raise('query-item-too-large', `a link of ${file.path} alone is over the ${QUERY_REPLY_CAP}-character cap`);
|
||||
while (lines.length && nLinks > 0 && bytes(page(1, nLinks)) > QUERY_REPLY_CAP) nLinks = Math.floor(nLinks / 2);
|
||||
let nLines = Math.max(lines.length ? 1 : 0, count(lines, QUERY_REPLY_CAP - bytes(page(0, nLinks))));
|
||||
while (nLines > 1 && bytes(page(nLines, nLinks)) > QUERY_REPLY_CAP) nLines -= 1;
|
||||
return checked(page(nLines, nLinks));
|
||||
}
|
||||
|
||||
// search: lines whose text holds `text`, case-insensitive. The excerpt is the whole line. Up to 50 per page.
|
||||
export async function search(source, { universe: name, text, book, scope, offset } = {}) {
|
||||
const universe = universeArg(source.mapping, name);
|
||||
const needle = textArg(text, 'text').toLowerCase();
|
||||
if (!needle) invalid('text must not be empty');
|
||||
const { index, files } = await visible(source, universe, {
|
||||
scope: scopeArg(scope), book: book === undefined ? undefined : textArg(book, 'book'),
|
||||
});
|
||||
const hits = [];
|
||||
for (const file of files) {
|
||||
for (const [line, body] of file.lines) {
|
||||
if (!body.toLowerCase().includes(needle)) continue;
|
||||
const hit = { path: file.path, line, excerpt: body, scope: file.scope };
|
||||
// The excerpt is the whole line. A line too long to fit alone is marked, not cut and not refused.
|
||||
hits.push(bytes({ commit: index.commit, results: [hit], next: Number.MAX_SAFE_INTEGER }) > QUERY_REPLY_CAP
|
||||
? { path: file.path, line, excerpt: null, excerpt_too_long: true, scope: file.scope } : hit);
|
||||
}
|
||||
}
|
||||
return fitPage(hits, {
|
||||
offset: offsetArg(offset),
|
||||
build: (picked, next) => ({ commit: index.commit, results: picked, next }),
|
||||
});
|
||||
}
|
||||
|
||||
// manuscript: one chapter file of a book, by its file name, up to 400 lines per call from `from` to `to`.
|
||||
// Without a chapter it returns the book's first chapter file in path order. Every reply carries
|
||||
// `next_chapter`, the next chapter file's name in path order or null. A name that matches two visible
|
||||
// files is refused. Files outside every book (book null) are never chapters of a book.
|
||||
export async function manuscript(source, { universe: name, book, scope, chapter, from, to } = {}) {
|
||||
const universe = universeArg(source.mapping, name);
|
||||
const bookName = textArg(book, 'book');
|
||||
const chapterName = chapter === undefined ? undefined : textArg(chapter, 'chapter');
|
||||
const start = positionArg(from, 'from', 1);
|
||||
const end = positionArg(to, 'to', Infinity);
|
||||
if (end < start) invalid('to must not be before from');
|
||||
const { index, files } = await visible(source, universe, { scope: scopeArg(scope), book: bookName, type: 'manuscript' });
|
||||
let at = 0;
|
||||
if (chapterName !== undefined) {
|
||||
const matches = files.filter(file => file.stem === chapterName);
|
||||
if (matches.length > 1) raise('query-chapter-ambiguous', `${matches.length} visible files are named ${JSON.stringify(chapterName)}`);
|
||||
if (!matches.length) return null;
|
||||
at = files.indexOf(matches[0]);
|
||||
}
|
||||
const file = files[at];
|
||||
if (!file) return null;
|
||||
const nextChapter = files[at + 1]?.stem ?? null;
|
||||
return fitPage(file.lines.slice(start - 1, end), {
|
||||
limit: MANUSCRIPT_LINES,
|
||||
build: (picked, next) => ({
|
||||
path: file.path, commit: index.commit, scope: file.scope, lines: picked,
|
||||
next: next === null ? null : start + next, next_chapter: nextChapter,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// universeInfo: a universe's kind, name, books, playground prefixes, type folders and manuscript folders.
|
||||
// A hybrid adds its sources, constraints and notes. A draft has no books and no canon: its kind, name
|
||||
// and prefixes are the whole answer beside the mapping's types. It reads only the checked mapping and the
|
||||
// served commit.
|
||||
export async function universeInfo(source, { universe: name } = {}) {
|
||||
const universe = universeArg(source.mapping, name);
|
||||
const entry = source.mapping.universes[universe];
|
||||
const hybrid = entry.kind === 'hybrid';
|
||||
return checked({
|
||||
commit: source.commit, universe, name: entry.name, kind: entry.kind ?? 'universe',
|
||||
books: entry.books, playground: entry.playground, types: source.mapping.types, manuscript: source.mapping.manuscript,
|
||||
...(hybrid ? {
|
||||
sources: entry.sources, constraints: entry.constraints,
|
||||
...(entry.notes === undefined ? {} : { notes: entry.notes }),
|
||||
} : {}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Index tests (C2-05a): the cache key and its bound, failed reads, mapped-only contents, and the rebuild
|
||||
// time on a 3,000-file fixture. Fixtures are made up and built under the scratch area.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { clearIndexCache, indexCacheSize, indexFor } from '../src/index-build.mjs';
|
||||
import { git, mapping, MARKER } from './helpers.mjs';
|
||||
import { note, scratchRepo } from './query-fixture.mjs';
|
||||
|
||||
const FILES = {
|
||||
'Book One/Characters/Mara Vell.md': note('character', 'Mara Vell', MARKER),
|
||||
'Book One/Chapters/Chapter 01.md': '# One\n[[Mara Vell]]\n',
|
||||
'Inbox Note.md': `${MARKER}\n`,
|
||||
};
|
||||
|
||||
// A mapping whose harbor universe has the given display name. Each name gives a new cache key.
|
||||
const named = name => mapping(e => ({ ...e, universes: { ...e.universes, harbor: { ...e.universes.harbor, name } } }));
|
||||
|
||||
test('the same commit, repo path and mapping return the same index, and only mapped files are in it', async t => {
|
||||
clearIndexCache();
|
||||
const { repo, commit } = scratchRepo(t, FILES);
|
||||
const a = await indexFor({ repo, commit, mapping: mapping() });
|
||||
const b = await indexFor({ repo, commit, mapping: mapping() });
|
||||
assert.equal(a, b);
|
||||
assert.equal(indexCacheSize(), 1);
|
||||
assert.equal(a.commit, commit);
|
||||
assert.ok(a.files.has('Book One/Characters/Mara Vell.md'));
|
||||
assert.ok(!a.files.has('Inbox Note.md'));
|
||||
assert.deepEqual(a.names.get('mara vell'), ['Book One/Characters/Mara Vell.md']);
|
||||
assert.deepEqual(a.linksIn.get('Book One/Characters/Mara Vell.md').map(l => l.path), ['Book One/Chapters/Chapter 01.md']);
|
||||
});
|
||||
|
||||
test('a different repo path, mapping or commit gets its own index', async t => {
|
||||
clearIndexCache();
|
||||
const { root, repo, commit } = scratchRepo(t, FILES);
|
||||
const base = await indexFor({ repo, commit, mapping: mapping() });
|
||||
const alias = join(root, 'alias');
|
||||
symlinkSync(repo, alias);
|
||||
assert.notEqual(await indexFor({ repo: alias, commit, mapping: mapping() }), base);
|
||||
assert.notEqual(await indexFor({ repo, commit, mapping: named('Renamed Harbor') }), base);
|
||||
writeFileSync(join(repo, 'Book One/Places/New.md'), note('place', 'New'));
|
||||
git(repo, 'add', '-A');
|
||||
git(repo, 'commit', '-qm', 'second');
|
||||
const next = git(repo, 'rev-parse', 'HEAD');
|
||||
const later = await indexFor({ repo, commit: next, mapping: mapping() });
|
||||
assert.notEqual(later, base);
|
||||
assert.equal(later.commit, next);
|
||||
assert.ok(later.files.has('Book One/Places/New.md'));
|
||||
});
|
||||
|
||||
test('the cache holds at most eight indexes and evicts the least recently used', async t => {
|
||||
clearIndexCache();
|
||||
const { repo, commit } = scratchRepo(t, FILES);
|
||||
const maps = Array.from({ length: 9 }, (_, i) => named(`Harbor ${i}`));
|
||||
const first = await indexFor({ repo, commit, mapping: maps[0] });
|
||||
for (const m of maps.slice(1)) await indexFor({ repo, commit, mapping: m });
|
||||
assert.equal(indexCacheSize(), 8);
|
||||
assert.notEqual(await indexFor({ repo, commit, mapping: maps[0] }), first);
|
||||
assert.equal(indexCacheSize(), 8);
|
||||
});
|
||||
|
||||
test('a failed read is not kept in the cache', async t => {
|
||||
clearIndexCache();
|
||||
const { repo } = scratchRepo(t, FILES);
|
||||
await assert.rejects(indexFor({ repo, commit: '0'.repeat(40), mapping: mapping() }));
|
||||
assert.equal(indexCacheSize(), 0);
|
||||
});
|
||||
|
||||
test('a missing or unchecked mapping is refused before any read', async t => {
|
||||
clearIndexCache();
|
||||
const { repo, commit } = scratchRepo(t, FILES);
|
||||
await assert.rejects(indexFor({ repo, commit, mapping: undefined }), { code: 'library-mapping-invalid' });
|
||||
await assert.rejects(indexFor({ repo, commit, mapping: { kind: 'hybrid' } }), { code: 'library-mapping-invalid' });
|
||||
});
|
||||
|
||||
test('a 3,000-file rebuild completes and its time is reported', { timeout: 300_000 }, async t => {
|
||||
clearIndexCache();
|
||||
const folders = ['Characters', 'Places', 'Research'];
|
||||
const files = {};
|
||||
for (let i = 0; i < 3000; i++) {
|
||||
files[`Book One/${folders[i % 3]}/Note ${i}.md`] = note('character', `Note ${i}`, `Body ${i} [[Note ${(i + 1) % 3000}]]`);
|
||||
}
|
||||
const { repo, commit } = scratchRepo(t, files);
|
||||
const started = performance.now();
|
||||
const index = await indexFor({ repo, commit, mapping: mapping() });
|
||||
const ms = performance.now() - started;
|
||||
t.diagnostic(`3,000-file index rebuild: ${ms.toFixed(0)} ms`);
|
||||
assert.equal(index.paths.length, 3000);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
// Scratch repos for the query and index tests (C2-05a). They are built under tmpdir(), as helpers.mjs
|
||||
// repo() does; the gate sets TMPDIR under /mnt/storage/scratch/tmp, never /tmp (RULINGS "tmpdir flag").
|
||||
// Bodies are made up.
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { git } from './helpers.mjs';
|
||||
|
||||
// A repo with one commit holding `files` (path -> text). Removed when the test ends.
|
||||
export function scratchRepo(t, files) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'r216-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const repo = join(root, 'books');
|
||||
mkdirSync(repo);
|
||||
git(repo, 'init', '-q', '-b', 'main');
|
||||
for (const [path, text] of Object.entries(files)) {
|
||||
mkdirSync(dirname(join(repo, path)), { recursive: true });
|
||||
writeFileSync(join(repo, path), text);
|
||||
}
|
||||
git(repo, 'add', '-A');
|
||||
git(repo, 'commit', '-qm', 'fixture');
|
||||
return { root, repo, commit: git(repo, 'rev-parse', 'HEAD') };
|
||||
}
|
||||
|
||||
// A note: front matter with a type and a name, optional extra front matter lines, then a body.
|
||||
export const note = (type, name, body = '', extra = []) => ['---', `type: ${type}`, `name: ${name}`, ...extra, '---', body, ''].join('\n');
|
||||
|
||||
// The one exclusion the hybrid tests use, and the paths it hides. C2-15 (row 267) replaces the reading of
|
||||
// `exclude` (a path prefix today); swapping these two constants is the whole change to the tests.
|
||||
export const EXCLUSION = { exclude: 'Book One/Research' };
|
||||
export const EXCLUDED_PATHS = ['Book One/Research/Ferry Logs.md'];
|
||||
@@ -0,0 +1,369 @@
|
||||
// Query tests for design 3 (C2-05a): universe isolation, scope labels, unmapped files, hybrids, paging
|
||||
// under the reply cap, refusals and manuscript paging. Fixtures are made up and built under the scratch area.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { REPLY_CAP } from '../../harness/src/tools.mjs';
|
||||
import { find, get, search, manuscript, universeInfo, QUERY_REPLY_CAP, LOOKUP_LIMIT, MANUSCRIPT_LINES } from '../src/query.mjs';
|
||||
import { mapping, MARKER } from './helpers.mjs';
|
||||
import { scratchRepo, note, EXCLUSION, EXCLUDED_PATHS } from './query-fixture.mjs';
|
||||
|
||||
// A hybrid over harbor and second. Its one constraint is EXCLUSION (query-fixture.mjs) and it declares no
|
||||
// books or playground.
|
||||
const WITH_BLEND = e => ({
|
||||
...e,
|
||||
universes: {
|
||||
...e.universes,
|
||||
blend: {
|
||||
kind: 'hybrid', name: 'Blend', sources: ['harbor', 'second'], books: [], playground: [],
|
||||
constraints: [EXCLUSION], notes: 'made-up hybrid',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// PRD 1.14 levels (C2-01c): harbor gets a universe-level playground prefix outside every book, and a draft
|
||||
// universe has only playground prefixes. Files there have book null.
|
||||
const WITH_LEVELS = e => ({
|
||||
...WITH_BLEND(e),
|
||||
universes: {
|
||||
...WITH_BLEND(e).universes,
|
||||
harbor: { ...e.universes.harbor, playground: ['Book One/Playground/', 'Notes/Harbor/'] },
|
||||
idea: { kind: 'draft', name: 'New World', playground: ['Drafts/New World/'] },
|
||||
},
|
||||
});
|
||||
const LEVEL_FILES = {
|
||||
'Notes/Harbor/Scraps.md': note('summary', 'Scraps', `${MARKER} loose`),
|
||||
'Notes/Harbor/Chapters/Idea Chapter.md': `# Idea\n${MARKER} unbooked chapter\n`,
|
||||
'Drafts/New World/Characters/Rin.md': note('character', 'Rin', `${MARKER} draft`),
|
||||
'Drafts/New World/Summary.md': note('summary', 'Summary', `${MARKER} draft`),
|
||||
};
|
||||
|
||||
const FILES = {
|
||||
'Book One/Characters/Mara Vell.md': note('character', 'Mara Vell', `Grey eyes. ${MARKER}`,
|
||||
['appears_in: "[[Book One/Chapters/Chapter 01]]"', 'relationships:', ' - "[[Tomas]]"']),
|
||||
'Book One/Characters/Tomas.md': note('character', 'Tomas', MARKER),
|
||||
'Book One/Chapters/Chapter 01.md': `# One\n${MARKER} [[Mara Vell]] met [[Book One/Places/Harbor Town|the harbor]].\n`,
|
||||
'Book One/Chapters/Chapter 02.md': 'Quiet. [[Ferry Logs]] [[Ilse]]\n',
|
||||
'Book One/Places/Harbor Town.md': note('place', 'Harbor Town', `Salt. ${MARKER}`),
|
||||
'Book One/Research/Ferry Logs.md': note('research', 'Ferry Logs', `Timetable. ${MARKER}`),
|
||||
'Book One/Playground/What If.md': note('summary', 'What If', `${MARKER} [[Mara Vell]]`),
|
||||
'Second World/Characters/Ilse.md': note('character', 'Ilse', `${MARKER} Ilse.`),
|
||||
'Second World/Chapters/01.md': '# Elsewhere\n[[Ilse]] waits.\n',
|
||||
'Inbox Note.md': `${MARKER}\n`,
|
||||
'Book One/Board.canvas': '{"nodes":[]}\n',
|
||||
};
|
||||
|
||||
const src = (ref, map = mapping()) => ({ repo: ref.repo, commit: ref.commit, mapping: map });
|
||||
const paths = reply => reply.results.map(r => r.path);
|
||||
|
||||
test('the query reply cap equals the harness reply cap', () => {
|
||||
assert.equal(QUERY_REPLY_CAP, REPLY_CAP);
|
||||
});
|
||||
|
||||
test('find is bound to its universe and refuses a call without one', async t => {
|
||||
const s = src(scratchRepo(t, FILES));
|
||||
const names = paths(await find(s, { universe: 'harbor', query: '' }));
|
||||
assert.ok(names.length > 0);
|
||||
assert.ok(names.every(p => p.startsWith('Book One/')));
|
||||
assert.ok(!names.includes('Second World/Characters/Ilse.md'));
|
||||
await assert.rejects(find(s, { query: '' }), { code: 'query-argument-invalid' });
|
||||
await assert.rejects(find(s, { universe: 'nowhere', query: '' }), { code: 'query-universe-unknown' });
|
||||
});
|
||||
|
||||
test('every result carries its scope, and playground appears only when asked for', async t => {
|
||||
const s = src(scratchRepo(t, FILES));
|
||||
const canon = (await find(s, { universe: 'harbor', query: '' })).results;
|
||||
assert.ok(canon.every(r => r.scope === 'canon'));
|
||||
assert.ok(!canon.some(r => r.path.includes('/Playground/')));
|
||||
const play = (await find(s, { universe: 'harbor', query: '', scope: 'playground' })).results;
|
||||
assert.deepEqual(play.map(r => r.path), ['Book One/Playground/What If.md']);
|
||||
assert.ok(play.every(r => r.scope === 'playground'));
|
||||
});
|
||||
|
||||
test('unmapped files never appear in any reply', async t => {
|
||||
const s = src(scratchRepo(t, FILES));
|
||||
assert.deepEqual((await find(s, { universe: 'harbor', query: 'Inbox' })).results, []);
|
||||
assert.deepEqual((await find(s, { universe: 'harbor', query: 'Board' })).results, []);
|
||||
const hits = (await search(s, { universe: 'harbor', text: MARKER })).results;
|
||||
assert.ok(hits.length > 0);
|
||||
assert.ok(hits.every(r => r.path !== 'Inbox Note.md' && r.path !== 'Book One/Board.canvas'));
|
||||
});
|
||||
|
||||
test('a hybrid sees its sources canon, minus exclusions, and no playground', async t => {
|
||||
const s = src(scratchRepo(t, FILES), mapping(WITH_BLEND));
|
||||
const reply = await find(s, { universe: 'blend', query: '' });
|
||||
assert.deepEqual(paths(reply).sort(), [
|
||||
'Book One/Characters/Mara Vell.md', 'Book One/Characters/Tomas.md', 'Book One/Chapters/Chapter 01.md',
|
||||
'Book One/Chapters/Chapter 02.md', 'Book One/Places/Harbor Town.md',
|
||||
'Second World/Characters/Ilse.md', 'Second World/Chapters/01.md',
|
||||
]);
|
||||
assert.ok(reply.results.every(r => r.scope === 'canon' && ['harbor', 'second'].includes(r.universe)));
|
||||
assert.deepEqual((await find(s, { universe: 'blend', query: '', scope: 'playground' })).results, []);
|
||||
assert.deepEqual((await find(s, { universe: 'blend', query: 'Ferry' })).results, []);
|
||||
});
|
||||
|
||||
test('a hybrid gets a source file it can see, and not an excluded or foreign one', async t => {
|
||||
const ref = scratchRepo(t, FILES);
|
||||
const hybrid = src(ref, mapping(WITH_BLEND));
|
||||
const ilse = await get(hybrid, { universe: 'blend', path: 'Second World/Characters/Ilse.md' });
|
||||
assert.equal(ilse.scope, 'canon');
|
||||
assert.ok(ilse.links_in.some(l => l.path === 'Second World/Chapters/01.md'));
|
||||
assert.equal(await get(hybrid, { universe: 'blend', path: 'Book One/Research/Ferry Logs.md' }), null);
|
||||
assert.equal(await get(src(ref), { universe: 'harbor', path: 'Second World/Characters/Ilse.md' }), null);
|
||||
});
|
||||
|
||||
test('get lists visible links both ways and drops a target another universe cannot see', async t => {
|
||||
const ref = scratchRepo(t, FILES);
|
||||
const harbor = src(ref);
|
||||
const ch1 = await get(harbor, { universe: 'harbor', path: 'Book One/Chapters/Chapter 01.md' });
|
||||
assert.ok(ch1.links_out.some(l => l.path === 'Book One/Characters/Mara Vell.md' && l.line === 2));
|
||||
assert.ok(ch1.links_out.some(l => l.path === 'Book One/Places/Harbor Town.md'));
|
||||
const ch2 = await get(harbor, { universe: 'harbor', path: 'Book One/Chapters/Chapter 02.md' });
|
||||
assert.ok(ch2.links_out.some(l => l.path === 'Book One/Research/Ferry Logs.md'));
|
||||
assert.ok(!ch2.links_out.some(l => l.path === 'Second World/Characters/Ilse.md'));
|
||||
const mara = await get(harbor, { universe: 'harbor', path: 'Book One/Characters/Mara Vell.md' });
|
||||
assert.ok(mara.links_in.some(l => l.path === 'Book One/Chapters/Chapter 01.md'));
|
||||
assert.ok(mara.links_in.some(l => l.path === 'Book One/Playground/What If.md'));
|
||||
assert.ok(mara.links_out.some(l => l.path === 'Book One/Characters/Tomas.md' && l.field === 'relationships'));
|
||||
assert.ok(mara.links_out.some(l => l.path === 'Book One/Chapters/Chapter 01.md' && l.field === 'appears_in'));
|
||||
const blendCh2 = await get(src(ref, mapping(WITH_BLEND)), { universe: 'blend', path: 'Book One/Chapters/Chapter 02.md' });
|
||||
assert.ok(blendCh2.links_out.some(l => l.path === 'Second World/Characters/Ilse.md'));
|
||||
});
|
||||
|
||||
test('a long list pages by position, never cuts an item, and returns 50 per page', async t => {
|
||||
const files = { ...FILES };
|
||||
for (let i = 1; i <= 120; i++) files[`Book One/Places/Note ${i}.md`] = note('place', `Note ${i}`);
|
||||
const s = src(scratchRepo(t, files));
|
||||
const seen = [];
|
||||
let offset = 0;
|
||||
for (let pages = 0; pages < 10; pages++) {
|
||||
const reply = await find(s, { universe: 'harbor', query: 'Note', offset });
|
||||
assert.ok(JSON.stringify(reply).length <= QUERY_REPLY_CAP);
|
||||
assert.ok(reply.results.length <= LOOKUP_LIMIT);
|
||||
if (pages === 0) assert.equal(reply.results.length, LOOKUP_LIMIT);
|
||||
seen.push(...paths(reply));
|
||||
if (reply.next === null) break;
|
||||
offset = reply.next;
|
||||
}
|
||||
assert.equal(seen.length, 120);
|
||||
assert.equal(new Set(seen).size, 120);
|
||||
});
|
||||
|
||||
test('a page stays under the reply cap when items are large', async t => {
|
||||
const big = 'x'.repeat(2000);
|
||||
const files = { ...FILES };
|
||||
for (let i = 1; i <= 40; i++) files[`Book One/Places/Big ${i}.md`] = note('place', `Big ${i}`, '', [`appears_in: "${big}"`]);
|
||||
const s = src(scratchRepo(t, files));
|
||||
const first = await find(s, { universe: 'harbor', query: 'Big' });
|
||||
assert.ok(JSON.stringify(first).length <= QUERY_REPLY_CAP);
|
||||
assert.ok(first.results.length > 0 && first.results.length < 40);
|
||||
assert.notEqual(first.next, null);
|
||||
const seen = [];
|
||||
let offset = 0;
|
||||
for (let pages = 0; pages < 10; pages++) {
|
||||
const reply = await find(s, { universe: 'harbor', query: 'Big', offset });
|
||||
assert.ok(JSON.stringify(reply).length <= QUERY_REPLY_CAP);
|
||||
seen.push(...paths(reply));
|
||||
if (reply.next === null) break;
|
||||
offset = reply.next;
|
||||
}
|
||||
assert.equal(new Set(seen).size, 40);
|
||||
});
|
||||
|
||||
test('a search line too long to fit alone comes back marked, with no excerpt, and the other hits still return', async t => {
|
||||
const huge = 'y'.repeat(QUERY_REPLY_CAP + 1000);
|
||||
const s = src(scratchRepo(t, { ...FILES, 'Book One/Places/Wall.md': note('place', 'Wall', `${huge} ${MARKER}`) }));
|
||||
const reply = await search(s, { universe: 'harbor', text: 'yyyyyyyy' });
|
||||
assert.deepEqual(reply.results, [{ path: 'Book One/Places/Wall.md', line: 5, excerpt: null, excerpt_too_long: true, scope: 'canon' }]);
|
||||
const mixed = await search(s, { universe: 'harbor', text: MARKER.toLowerCase() });
|
||||
assert.ok(mixed.results.some(r => r.excerpt_too_long === true));
|
||||
assert.ok(mixed.results.some(r => typeof r.excerpt === 'string' && !('excerpt_too_long' in r)));
|
||||
assert.ok(JSON.stringify(mixed).length <= QUERY_REPLY_CAP);
|
||||
});
|
||||
|
||||
test('a find item that cannot fit alone is refused, not cut', async t => {
|
||||
const huge = 'y'.repeat(QUERY_REPLY_CAP + 1000);
|
||||
const s = src(scratchRepo(t, { ...FILES, 'Book One/Places/Wall.md': note('place', 'Wall', '', [`appears_in: "${huge}"`]) }));
|
||||
await assert.rejects(find(s, { universe: 'harbor', query: 'Wall' }), { code: 'query-item-too-large' });
|
||||
});
|
||||
|
||||
test('get pages its lines, and the next line picks up where the page stopped', async t => {
|
||||
const body = `${Array.from({ length: 500 }, (_, i) => `Line ${i + 1} ${'z'.repeat(120)}`).join('\n')}\n`;
|
||||
const s = src(scratchRepo(t, { ...FILES, 'Book One/Chapters/Long.md': body }));
|
||||
const first = await get(s, { universe: 'harbor', path: 'Book One/Chapters/Long.md' });
|
||||
assert.equal(first.lines[0][0], 1);
|
||||
assert.ok(first.lines.length < 501);
|
||||
assert.notEqual(first.next, null);
|
||||
let from = 1, count = 0;
|
||||
for (let pages = 0; pages < 10; pages++) {
|
||||
const page = await get(s, { universe: 'harbor', path: 'Book One/Chapters/Long.md', from });
|
||||
count += page.lines.length;
|
||||
if (page.next === null) break;
|
||||
from = page.next;
|
||||
}
|
||||
assert.equal(count, 501);
|
||||
});
|
||||
|
||||
test('get refuses a file whose front matter alone is over the cap, and a line alone is over the cap', async t => {
|
||||
const huge = 'q'.repeat(QUERY_REPLY_CAP + 1000);
|
||||
const s = src(scratchRepo(t, {
|
||||
...FILES,
|
||||
'Book One/Places/Heavy.md': note('place', 'Heavy', '', [`appears_in: "${huge}"`]),
|
||||
'Book One/Chapters/Wide.md': `${huge}\n`,
|
||||
}));
|
||||
await assert.rejects(get(s, { universe: 'harbor', path: 'Book One/Places/Heavy.md' }), { code: 'query-item-too-large' });
|
||||
await assert.rejects(get(s, { universe: 'harbor', path: 'Book One/Chapters/Wide.md' }), { code: 'query-item-too-large' });
|
||||
});
|
||||
|
||||
test('get pages the links of a hub note, every page carries the front matter, and the pages add up', async t => {
|
||||
const files = { ...FILES };
|
||||
for (let i = 1; i <= 900; i++) files[`Book One/Chapters/Mention ${i}.md`] = `[[Tomas]] ${'w'.repeat(60)}\n`;
|
||||
const s = src(scratchRepo(t, files));
|
||||
const seen = [];
|
||||
const PAST_END = 1000;
|
||||
let linksFrom = 0, pages = 0, lines = 0, from = 1;
|
||||
for (; pages < 40; pages++) {
|
||||
const page = await get(s, { universe: 'harbor', path: 'Book One/Characters/Tomas.md', from, links_from: linksFrom });
|
||||
assert.ok(JSON.stringify(page).length <= QUERY_REPLY_CAP);
|
||||
assert.equal(page.frontmatter.name, 'Tomas');
|
||||
seen.push(...page.links_in.map(l => l.path));
|
||||
if (from !== PAST_END) lines += page.lines.length;
|
||||
// Once the lines are read, ask past the last one: the page then carries links alone.
|
||||
from = page.next ?? PAST_END;
|
||||
if (page.links_next === null && page.next === null) break;
|
||||
if (page.links_next !== null) linksFrom = page.links_next;
|
||||
}
|
||||
assert.ok(pages > 1, 'the hub needed more than one page');
|
||||
assert.ok(pages < 40);
|
||||
assert.equal(seen.filter(p => p.startsWith('Book One/Chapters/Mention ')).length, 900);
|
||||
assert.equal(new Set(seen).size, seen.length, 'no link comes back twice');
|
||||
assert.equal(lines, 6, 'the five lines of the note and the empty one after its last newline');
|
||||
const tail = await get(s, { universe: 'harbor', path: 'Book One/Characters/Tomas.md', from: PAST_END });
|
||||
assert.deepEqual(tail.lines, []);
|
||||
assert.equal(tail.next, null);
|
||||
});
|
||||
|
||||
test('get rejects a bad links_from', async t => {
|
||||
const s = src(scratchRepo(t, FILES));
|
||||
await assert.rejects(get(s, { universe: 'harbor', path: 'Book One/Characters/Tomas.md', links_from: -1 }), { code: 'query-argument-invalid' });
|
||||
const past = await get(s, { universe: 'harbor', path: 'Book One/Characters/Tomas.md', links_from: 99 });
|
||||
assert.deepEqual([past.links_out, past.links_in, past.links_next], [[], [], null]);
|
||||
});
|
||||
|
||||
test('search returns scoped line hits, case-insensitively, and refuses empty text', async t => {
|
||||
const s = src(scratchRepo(t, FILES));
|
||||
const reply = await search(s, { universe: 'harbor', text: 'marker' });
|
||||
assert.ok(reply.results.length > 0);
|
||||
assert.ok(reply.results.every(r => r.scope === 'canon' && r.path.startsWith('Book One/') && Number.isInteger(r.line)));
|
||||
assert.ok(reply.results.some(r => r.path === 'Book One/Chapters/Chapter 01.md' && r.line === 2));
|
||||
await assert.rejects(search(s, { universe: 'harbor', text: '' }), { code: 'query-argument-invalid' });
|
||||
});
|
||||
|
||||
test('a manuscript call returns one chapter, at most 400 lines, and names its next line', async t => {
|
||||
const long = `${Array.from({ length: 900 }, (_, i) => `Scene ${i + 1}`).join('\n')}\n`;
|
||||
const s = src(scratchRepo(t, { ...FILES, 'Book One/Chapters/Long Chapter.md': long }));
|
||||
const first = await manuscript(s, { universe: 'harbor', book: 'Book One', chapter: 'Long Chapter' });
|
||||
assert.equal(first.lines.length, MANUSCRIPT_LINES);
|
||||
assert.equal(first.next, MANUSCRIPT_LINES + 1);
|
||||
const second = await manuscript(s, { universe: 'harbor', book: 'Book One', chapter: 'Long Chapter', from: first.next });
|
||||
assert.equal(second.lines[0][0], MANUSCRIPT_LINES + 1);
|
||||
});
|
||||
|
||||
test('a manuscript call without a chapter returns the first chapter in path order and names the next', async t => {
|
||||
const s = src(scratchRepo(t, FILES));
|
||||
const first = await manuscript(s, { universe: 'harbor', book: 'Book One' });
|
||||
assert.equal(first.path, 'Book One/Chapters/Chapter 01.md');
|
||||
assert.equal(first.next_chapter, 'Chapter 02');
|
||||
const last = await manuscript(s, { universe: 'harbor', book: 'Book One', chapter: 'Chapter 02' });
|
||||
assert.equal(last.path, 'Book One/Chapters/Chapter 02.md');
|
||||
assert.equal(last.next_chapter, null);
|
||||
assert.equal(await manuscript(s, { universe: 'harbor', book: 'Book One', chapter: 'Nope' }), null);
|
||||
});
|
||||
|
||||
test('a chapter name that matches two visible files is refused', async t => {
|
||||
const s = src(scratchRepo(t, { ...FILES, 'Book One/Chapters/Part/Chapter 01.md': 'Again.\n' }));
|
||||
await assert.rejects(manuscript(s, { universe: 'harbor', book: 'Book One', chapter: 'Chapter 01' }), { code: 'query-chapter-ambiguous' });
|
||||
});
|
||||
|
||||
test('a hybrid reads a source chapter through its declared sources only', async t => {
|
||||
const ref = scratchRepo(t, FILES);
|
||||
const hybrid = await manuscript(src(ref, mapping(WITH_BLEND)), { universe: 'blend', book: 'Second World', chapter: '01' });
|
||||
assert.equal(hybrid.path, 'Second World/Chapters/01.md');
|
||||
assert.equal(hybrid.scope, 'canon');
|
||||
assert.equal(await manuscript(src(ref), { universe: 'harbor', book: 'Second World', chapter: '01' }), null);
|
||||
});
|
||||
|
||||
test('universe info reports a hybrid sources, constraints and notes, and refuses an unknown universe', async t => {
|
||||
const s = src(scratchRepo(t, FILES), mapping(WITH_BLEND));
|
||||
const blend = await universeInfo(s, { universe: 'blend' });
|
||||
assert.equal(blend.kind, 'hybrid');
|
||||
assert.deepEqual(blend.sources, ['harbor', 'second']);
|
||||
assert.deepEqual(blend.constraints, [EXCLUSION]);
|
||||
assert.equal(blend.notes, 'made-up hybrid');
|
||||
const harbor = await universeInfo(s, { universe: 'harbor' });
|
||||
assert.equal(harbor.kind, 'universe');
|
||||
assert.equal(harbor.sources, undefined);
|
||||
await assert.rejects(universeInfo(s, { universe: 'ghost' }), { code: 'query-universe-unknown' });
|
||||
});
|
||||
|
||||
test('an exclusion hides what it names from a hybrid and from nothing else', async t => {
|
||||
const ref = scratchRepo(t, FILES);
|
||||
const hybrid = src(ref, mapping(WITH_BLEND));
|
||||
const seen = paths(await find(hybrid, { universe: 'blend', query: '' }));
|
||||
for (const path of EXCLUDED_PATHS) assert.ok(!seen.includes(path), `${path} stays hidden`);
|
||||
const own = paths(await find(src(ref), { universe: 'harbor', query: '' }));
|
||||
for (const path of EXCLUDED_PATHS) assert.ok(own.includes(path), `${path} stays visible to its own universe`);
|
||||
});
|
||||
|
||||
test('PRD 1.14: a lookup that names a book leaves book-null files out', async t => {
|
||||
const s = src(scratchRepo(t, { ...FILES, ...LEVEL_FILES }), mapping(WITH_LEVELS));
|
||||
const all = paths(await find(s, { universe: 'harbor', query: '', scope: 'playground' }));
|
||||
assert.deepEqual(all.sort(), ['Book One/Playground/What If.md', 'Notes/Harbor/Chapters/Idea Chapter.md', 'Notes/Harbor/Scraps.md']);
|
||||
const booked = await find(s, { universe: 'harbor', query: '', scope: 'playground', book: 'Book One' });
|
||||
assert.deepEqual(paths(booked), ['Book One/Playground/What If.md']);
|
||||
assert.ok(booked.results.every(r => r.book === 'Book One'));
|
||||
const hits = (await search(s, { universe: 'harbor', text: 'unbooked', scope: 'playground' })).results;
|
||||
assert.deepEqual(hits.map(r => r.path), ['Notes/Harbor/Chapters/Idea Chapter.md']);
|
||||
assert.deepEqual((await search(s, { universe: 'harbor', text: 'unbooked', scope: 'playground', book: 'Book One' })).results, []);
|
||||
});
|
||||
|
||||
test('PRD 1.14: library_manuscript never reads a book-null file', async t => {
|
||||
const s = src(scratchRepo(t, { ...FILES, ...LEVEL_FILES }), mapping(WITH_LEVELS));
|
||||
// The unbooked file sits under a manuscript folder, so it has type manuscript and book null.
|
||||
const listed = await find(s, { universe: 'harbor', query: 'Idea', scope: 'playground' });
|
||||
assert.deepEqual(listed.results.map(r => [r.path, r.type, r.book]), [['Notes/Harbor/Chapters/Idea Chapter.md', 'manuscript', null]]);
|
||||
assert.equal(await manuscript(s, { universe: 'harbor', book: 'Book One', scope: 'playground', chapter: 'Idea Chapter' }), null);
|
||||
assert.equal(await manuscript(s, { universe: 'harbor', book: 'Book One', scope: 'playground' }), null);
|
||||
assert.equal(await manuscript(s, { universe: 'harbor', book: 'Notes', scope: 'playground', chapter: 'Idea Chapter' }), null);
|
||||
});
|
||||
|
||||
test('PRD 1.14: a draft universe has no canon, so its lookups return files only with scope playground', async t => {
|
||||
const s = src(scratchRepo(t, { ...FILES, ...LEVEL_FILES }), mapping(WITH_LEVELS));
|
||||
assert.deepEqual((await find(s, { universe: 'idea', query: '' })).results, []);
|
||||
assert.deepEqual((await search(s, { universe: 'idea', text: MARKER })).results, []);
|
||||
const found = await find(s, { universe: 'idea', query: '', scope: 'playground' });
|
||||
assert.deepEqual(paths(found).sort(), ['Drafts/New World/Characters/Rin.md', 'Drafts/New World/Summary.md']);
|
||||
assert.ok(found.results.every(r => r.scope === 'playground' && r.universe === 'idea' && r.book === null));
|
||||
const hits = (await search(s, { universe: 'idea', text: 'draft', scope: 'playground' })).results;
|
||||
assert.equal(hits.length, 2);
|
||||
const rin = await get(s, { universe: 'idea', path: 'Drafts/New World/Characters/Rin.md' });
|
||||
assert.equal(rin.scope, 'playground');
|
||||
assert.equal(await get(s, { universe: 'harbor', path: 'Drafts/New World/Characters/Rin.md' }), null);
|
||||
});
|
||||
|
||||
test('PRD 1.14: library_universe on a draft returns its kind, name and prefixes', async t => {
|
||||
const s = src(scratchRepo(t, { ...FILES, ...LEVEL_FILES }), mapping(WITH_LEVELS));
|
||||
const draft = await universeInfo(s, { universe: 'idea' });
|
||||
assert.equal(draft.kind, 'draft');
|
||||
assert.equal(draft.name, 'New World');
|
||||
assert.deepEqual(draft.playground, ['Drafts/New World/']);
|
||||
assert.deepEqual(draft.books, []);
|
||||
assert.equal(draft.sources, undefined);
|
||||
});
|
||||
|
||||
test('a hybrid gets a source playground file as null, while the source gets it with scope playground', async t => {
|
||||
const ref = scratchRepo(t, FILES);
|
||||
const path = 'Book One/Playground/What If.md';
|
||||
assert.equal(await get(src(ref, mapping(WITH_BLEND)), { universe: 'blend', path }), null);
|
||||
const own = await get(src(ref), { universe: 'harbor', path });
|
||||
assert.equal(own.scope, 'playground');
|
||||
});
|
||||
@@ -335,28 +335,13 @@ note is `agents/dewey/work/wui/SLICE1-VIEWS.md`.
|
||||
same functions, so both show the same broker data.
|
||||
- Nothing writes but the "Message a role" box, the Settings update, the Assistant
|
||||
section's three buttons and the spec and docs editor's Save, all under `--send` (above,
|
||||
"Assistant settings" and "Spec and docs editor"), and the decision Approve and Deny below.
|
||||
A decision shows `mosaic decide <id> <key>` for each choice,
|
||||
"Assistant settings" and "Spec and docs editor"). A decision shows `mosaic decide <id> <key>` for each choice,
|
||||
with Copy, which only puts the command on the clipboard. It is answered in a
|
||||
terminal (REQ-DEC-3, Q4). Seen is not set from the browser either. The
|
||||
command has no `--business`, so `mosaic decide` uses the running bus host's
|
||||
business, as the Console does by default. From a Console started with
|
||||
`--business` for another business the copied command refuses (no bus host,
|
||||
or no such decision) rather than answering elsewhere.
|
||||
- Row 252 (#1753, PRD 1.9 REQ-WEB-2): a grant request (`credential.mint` routed
|
||||
to the human, raised with a grant and two options) can also be approved or
|
||||
denied on its decision page, only when the Console runs with both `--sso` and
|
||||
`--send` and the page is signed in as a `console.sso` subject. The first click
|
||||
POSTs `/api/decisions/<id>/confirmation` and shows what the answer does; only
|
||||
Confirm POSTs `/resolve` with that one-use confirmation (two minutes, bound to
|
||||
the session, decision and choice). Each step spawns
|
||||
`packages/bus/src/console-cli.mjs`, which the bus verifies as the Console's
|
||||
child holding the signed-in subject, and the bus records the answer `via
|
||||
console` with a `human.input` naming that subject. Nothing retries; an unknown
|
||||
outcome says to refresh. Without `--sso` the page has no buttons and says why;
|
||||
under `--sso` without `--send` it says the Console is read-only for decisions.
|
||||
The tester listener has no decision routes. Every other decision stays
|
||||
terminal-only.
|
||||
- What no Q1 verb returns is labelled where it would show, for example
|
||||
"Bot names: not in the Q1 module" (lead decision 63). Nothing is guessed.
|
||||
- A task's list row can only say its current state came from a Vikunja read,
|
||||
|
||||
+11
-34
@@ -10,11 +10,9 @@
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { HUMAN_CLI } from '../../cli/src/transport.mjs';
|
||||
import { readHostState } from '../../cli/src/host.mjs';
|
||||
import { views } from '../../bus/src/views.mjs';
|
||||
import { CONSOLE_VERBS } from '../../bus/src/broker.mjs';
|
||||
|
||||
export class BusReadError extends Error {
|
||||
constructor(code, message = code) { super(message); this.code = code; }
|
||||
@@ -28,13 +26,10 @@ export const subjectOk = s => typeof s === 'string' && s.length > 0 && s.length
|
||||
// request on stdin, JSON on stdout, or a bus error code as the last
|
||||
// `^[a-z-]{1,64}$` line of stderr.
|
||||
export function humanCall({ socket, business, env = process.env, cli = HUMAN_CLI, timeoutMs = 30000, maxBytes = 8 * 1024 * 1024 }) {
|
||||
return (verb, args = {}) => transport({ cli, socket, env: cli === HUMAN_CLI ? { ...env, MOSAIC_BUS_CLI_NONCE: randomBytes(32).toString('hex') } : env, input: { business, verb, args }, timeoutMs, maxBytes });
|
||||
}
|
||||
function transport({ cli, socket, env, input, timeoutMs, maxBytes }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
return (verb, args = {}) => new Promise((resolve, reject) => {
|
||||
let child;
|
||||
try {
|
||||
child = spawn(process.execPath, [cli, socket], { env, stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
child = spawn(process.execPath, [cli, socket], { env: cli === HUMAN_CLI ? { ...env, MOSAIC_BUS_CLI_NONCE: randomBytes(32).toString('hex') } : env, stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
} catch { return reject(new BusReadError('outcome-unknown', 'the bus transport did not start')); }
|
||||
const out = [], err = [];
|
||||
let size = 0, settled = false;
|
||||
@@ -57,42 +52,24 @@ function transport({ cli, socket, env, input, timeoutMs, maxBytes }) {
|
||||
catch { done(reject, new BusReadError('invalid-response')); }
|
||||
});
|
||||
child.stdin.on('error', () => {}); // A child that exits early reports through 'close'.
|
||||
child.stdin.end(`${JSON.stringify(input)}\n`);
|
||||
child.stdin.end(`${JSON.stringify({ business, verb, args })}\n`);
|
||||
});
|
||||
}
|
||||
|
||||
// Row 252 (#1753, design section 2): the Console owner's answer to a grant request. Each call spawns
|
||||
// a fresh console-cli with a fresh nonce, and the business and the signed-in (provider, iss, sub) go
|
||||
// in its spawn environment, where the bus reads them back. Inherited console keys are dropped first,
|
||||
// so only this call's values reach the wrapper. Only the two console verbs pass (N4). A resolve pins
|
||||
// the business its confirmation was issued for.
|
||||
export const CONSOLE_CLI = fileURLToPath(new URL('../../bus/src/console-cli.mjs', import.meta.url));
|
||||
export function consoleCall({ dataRoot, socket, business = null, env = process.env, cli = CONSOLE_CLI, timeoutMs = 30000 }) {
|
||||
return async (verb, args, { subject, business: pinned = null }) => {
|
||||
if (!CONSOLE_VERBS.includes(verb)) throw new Error(`the Console does not call ${verb}`);
|
||||
const id = pinned ?? (business || hostBusiness(dataRoot));
|
||||
const own = Object.fromEntries(Object.entries(env).filter(([k]) => !k.startsWith('MOSAIC_BUS_CONSOLE_')));
|
||||
return transport({
|
||||
cli, socket, timeoutMs, maxBytes: 1024 * 1024, input: { verb, args },
|
||||
env: { ...own, MOSAIC_BUS_CONSOLE_NONCE: randomBytes(32).toString('hex'), MOSAIC_BUS_CONSOLE_BUSINESS: id,
|
||||
MOSAIC_BUS_CONSOLE_PROVIDER: subject.provider, MOSAIC_BUS_CONSOLE_ISS: subject.iss, MOSAIC_BUS_CONSOLE_SUB: subject.sub },
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// The business is the --business flag, or else the live bus host's, read
|
||||
// on every request so a host started after the Console is picked up.
|
||||
export function busCaller({ dataRoot, socket, business = null, env = process.env, cli = HUMAN_CLI, timeoutMs = 30000 }) {
|
||||
return async (verb, args) => {
|
||||
return humanCall({ socket, business: business || hostBusiness(dataRoot), env, cli, timeoutMs })(verb, args);
|
||||
let id = business;
|
||||
if (!id) {
|
||||
let state;
|
||||
try { state = readHostState(dataRoot); } catch { throw new BusReadError('no-bus-host', 'the bus host state file is unreadable'); }
|
||||
if (!state?.live) throw new BusReadError('no-bus-host', 'no bus host is running and no --business was given');
|
||||
id = state.business;
|
||||
}
|
||||
return humanCall({ socket, business: id, env, cli, timeoutMs })(verb, args);
|
||||
};
|
||||
}
|
||||
function hostBusiness(dataRoot) {
|
||||
let state;
|
||||
try { state = readHostState(dataRoot); } catch { throw new BusReadError('no-bus-host', 'the bus host state file is unreadable'); }
|
||||
if (!state?.live) throw new BusReadError('no-bus-host', 'no bus host is running and no --business was given');
|
||||
return state.business;
|
||||
}
|
||||
export const busReader = options => views({ call: busCaller(options) });
|
||||
|
||||
// A send's error, sorted. The broker writes in one transaction, so any code it
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// MOSAIC_TESTER_SOCKET_V1: private tester ingress, no tester TCP.
|
||||
import { createChat } from './chat.mjs';
|
||||
import { startServer, previewOptions, proofOptions, DEFAULT_BOARD } from './serve.mjs';
|
||||
import { busCaller, consoleCall } from './bus.mjs';
|
||||
import { busCaller } from './bus.mjs';
|
||||
import { startTesterSocket } from './tester-socket.mjs';
|
||||
import { TESTER_CLI } from '../../cli/src/transport.mjs';
|
||||
import { views } from '../../bus/src/views.mjs';
|
||||
@@ -72,7 +72,7 @@ try {
|
||||
// the system config, Business can't resolve authority and Settings has
|
||||
// no notifier. A refusal printed here names no path (redactor).
|
||||
// --send without a bus refuses to start rather than serve a composer that can't send.
|
||||
let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, libraryFile = null, libraryStatus = null, librarySync = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, decisionWrite = null, busLine, system = null;
|
||||
let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, libraryFile = null, libraryStatus = null, librarySync = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, busLine, system = null;
|
||||
let processing = null;
|
||||
try {
|
||||
system = loadSystem();
|
||||
@@ -91,9 +91,6 @@ try {
|
||||
projectFile = args => call('project.file', args);
|
||||
libraryFile = args => call('library.file', args); libraryStatus = args => call('library.status', args);
|
||||
if (sendOn) {send = args => call('message.send', args);librarySync=args=>call('library.sync',args);settingsWrite=args=>call('limits.set',args);projectSave=args=>call('project.file.save',args);nameWrite=args=>call('name.set',args);assistantStop=args=>call('assistant.stop',args);instancesWrite=args=>call('instances.set',args);routesWrite=args=>call('routes.set',args);agentWrite=agentCaller(call,AGENT_VERBS);}
|
||||
// Row 252 (#1753): Approve and Deny on grant requests, through the console wrapper, only with
|
||||
// --sso and --send.
|
||||
if (ssoOn && sendOn) decisionWrite = consoleCall({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
|
||||
busLine = `Bus: ${options.business ?? 'the running bus host\'s business'}`;
|
||||
} catch (err) {
|
||||
const why = redactor({ configDir: configDir() })(err.message);
|
||||
@@ -113,7 +110,7 @@ try {
|
||||
}
|
||||
let server;
|
||||
try {
|
||||
server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, decisionWrite });
|
||||
server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing });
|
||||
} catch (err) { sso?.close(); throw err; }
|
||||
let testerServer = null;
|
||||
if (testerSocket) {
|
||||
@@ -137,7 +134,7 @@ try {
|
||||
const proofLine = `\nProxy: proxy proof ${proof ? 'on' : 'off'}`;
|
||||
// One line per provider: id, issuer, subject count and state. No client id, secret or sub.
|
||||
const health = sso?.health() ?? {};
|
||||
const ssoLines = sso ? `\nSign-in: ${sso.publicOrigin}/ (--sso), ${send ? 'reads and human writes (--send), grant Approve and Deny' : 'read-only'}` + sso.summary().map(p => `\nSign-in provider: ${p.id} issuer ${p.issuer} subjects ${p.subjects} (${health[p.id]})`).join('') : '';
|
||||
const ssoLines = sso ? `\nSign-in: ${sso.publicOrigin}/ (--sso), ${send ? 'reads and human writes (--send)' : 'read-only'}` + sso.summary().map(p => `\nSign-in provider: ${p.id} issuer ${p.issuer} subjects ${p.subjects} (${health[p.id]})`).join('') : '';
|
||||
const queueLine = options.checkout ? '\nQueue: read from the --checkout directory' : '';
|
||||
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}${ssoLines}${proofLine}${testerServer ? `\nTester Console: unix:${testerServer.address()} (binds the tester)` : ''}\nCtrl-C stops this server.`);
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { sso?.close(); server.close(); testerServer?.close(); });
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
// Row 252 (#1753, Gander's design approved by Webby at 5d8f0f4b5): Approve and Deny for a grant
|
||||
// request, from the signed-in Console only. Two POSTs. /confirmation asks the bus for the decision's
|
||||
// two answers and digest and returns a one-use confirmation bound to this session, business,
|
||||
// decision, choice and digest. /resolve takes only that choice and confirmation, consumes the
|
||||
// confirmation before it awaits the bus, and asks the bus to resolve with the digest. The bus
|
||||
// rechecks everything in the resolve transaction. Nothing here retries.
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { BusReadError, subjectOk } from './bus.mjs';
|
||||
|
||||
export const DECISION_ROUTE = /^\/api\/decisions\/([^/]*)\/(confirmation|resolve)$/;
|
||||
// The endpoint contract, design section 4. Each refusal is one of these, never a 200.
|
||||
export const DECISION_REFUSALS = Object.freeze({
|
||||
'sign-in-required': [401, 'Sign in again before answering.'],
|
||||
'read-only': [403, 'Console decision answers are not enabled. Use mosaic decide.'],
|
||||
'cross-origin': [403, 'Answer from the signed-in Console page.'],
|
||||
'not-found': [404, 'Not found.'],
|
||||
'human-required': [403, 'The bus refused this human identity.'],
|
||||
'peercred-required': [403, 'The bus could not verify this connection.'],
|
||||
'method-not-allowed': [405, 'Use POST.'],
|
||||
'invalid-request': [400, 'Invalid decision answer.'],
|
||||
'request-too-large': [413, 'Decision answer is too large.'],
|
||||
'decision-not-found': [404, 'This decision is unavailable.'],
|
||||
'decision-not-eligible': [403, 'Answer this decision with mosaic decide.'],
|
||||
'decision-closed': [409, 'This decision is no longer open. Refresh the inbox.'],
|
||||
'decision-stale': [409, 'The choices changed. Review the decision again.'],
|
||||
'confirmation-required': [409, 'Review and confirm this answer again.'],
|
||||
'confirmation-unavailable': [503, 'Confirmation is unavailable. Try again later.'],
|
||||
'outcome-unknown': [503, 'The answer may have reached the bus. Refresh before trying again.'],
|
||||
});
|
||||
// Bus codes by name. A bus that refuses this caller is human-required, whatever its code. Any
|
||||
// other code, or no answer, is confirmation-unavailable at the preview and outcome-unknown at the
|
||||
// resolve, which the page reads as "refresh first".
|
||||
const BUS_CODES = {
|
||||
'human-required': 'human-required', unauthenticated: 'human-required', 'unknown-business': 'human-required',
|
||||
'access-refused': 'human-required', 'access-revoked': 'human-required', 'agent-required': 'human-required',
|
||||
'peercred-required': 'peercred-required',
|
||||
'decision-not-found': 'decision-not-found', 'decision-not-eligible': 'decision-not-eligible',
|
||||
'decision-closed': 'decision-closed', 'decision-stale': 'decision-stale', 'invalid-choice': 'decision-stale',
|
||||
'invalid-request': 'invalid-request',
|
||||
};
|
||||
const BODY_MAX = 4096;
|
||||
const TOKEN = /^[a-f0-9]{64}$/;
|
||||
const DIGEST = /^[a-f0-9]{64}$/;
|
||||
const one = (req, name) => (req.headersDistinct[name]?.length === 1 ? req.headersDistinct[name][0] : null);
|
||||
const hash = (token) => createHash('sha256').update(token).digest('hex');
|
||||
export function decisionRefuse(res, code) {
|
||||
const [status, message] = DECISION_REFUSALS[code];
|
||||
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
||||
res.end(JSON.stringify({ error: code, message }));
|
||||
}
|
||||
|
||||
// The confirmations, in this process's memory only and stored by hash. 120 seconds, at most 1024
|
||||
// live. A full store refuses a new one rather than drop a live one. An expired one is swept.
|
||||
export function createConfirmations({ ttlMs = 120000, max = 1024, now = Date.now } = {}) {
|
||||
const live = new Map();
|
||||
const sweep = () => { const t = now(); for (const [k, v] of live) if (v.expires <= t) live.delete(k); };
|
||||
return {
|
||||
issue(binding) {
|
||||
sweep();
|
||||
if (live.size >= max) return null;
|
||||
const token = randomBytes(32).toString('hex');
|
||||
live.set(hash(token), { ...binding, expires: now() + ttlMs });
|
||||
return token;
|
||||
},
|
||||
// One use: a token found is gone, whether or not it matches.
|
||||
consume(token, { session, decision, choice }) {
|
||||
sweep();
|
||||
if (typeof token !== 'string' || !TOKEN.test(token)) return null;
|
||||
const k = hash(token), v = live.get(k);
|
||||
if (!v) return null;
|
||||
live.delete(k);
|
||||
return v.session === session && v.decision === decision && v.choice === choice ? v : null;
|
||||
},
|
||||
get size() { sweep(); return live.size; },
|
||||
};
|
||||
}
|
||||
|
||||
async function readBody(req) {
|
||||
if (one(req, 'content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') return { code: 'invalid-request' };
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
for await (const chunk of req) {
|
||||
size += chunk.length;
|
||||
if (size > BODY_MAX) return { code: 'request-too-large' };
|
||||
chunks.push(chunk);
|
||||
}
|
||||
try {
|
||||
const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? { value } : { code: 'invalid-request' };
|
||||
} catch { return { code: 'invalid-request' }; }
|
||||
}
|
||||
const preview = (v, id) =>
|
||||
v && typeof v === 'object' && v.id === id && typeof v.business === 'string' && DIGEST.test(v.digest ?? '') &&
|
||||
['approve', 'deny'].every((k) => typeof v[k]?.key === 'string' && typeof v[k]?.text === 'string') &&
|
||||
v.approve.key !== v.deny.key && v.grant && typeof v.grant.method === 'string';
|
||||
|
||||
// `session` is the live sign-in session object this request passed, and `live()` reads it again
|
||||
// right before each spawn. `call(verb, args, { subject, business })` is the console transport.
|
||||
export async function decisionHTTP(req, res, { path, decide, session, live, confirmations, log }) {
|
||||
const [, raw, step] = DECISION_ROUTE.exec(path);
|
||||
let id = null;
|
||||
try { id = decodeURIComponent(raw); } catch { /* refused below */ }
|
||||
if (req.method !== 'POST') return decisionRefuse(res, 'method-not-allowed');
|
||||
if (!decide) return decisionRefuse(res, 'read-only');
|
||||
if (!session) return decisionRefuse(res, 'sign-in-required');
|
||||
if (one(req, 'origin') !== decide.origin || one(req, 'sec-fetch-site') !== 'same-origin') return decisionRefuse(res, 'cross-origin');
|
||||
const line = (outcome) => log(`${new Date().toISOString()} decide user=${JSON.stringify(String(session.name ?? '').slice(0, 64))} decision=${JSON.stringify(String(id ?? '').slice(0, 64))} step=${step} ${outcome}`);
|
||||
const refuse = (code) => { line(`error=${code}`); return decisionRefuse(res, code); };
|
||||
const { value, code } = await readBody(req);
|
||||
if (code) return refuse(code);
|
||||
if (!subjectOk(id)) return refuse('decision-not-found');
|
||||
const subject = { provider: session.provider, iss: session.iss, sub: session.sub };
|
||||
const busCode = (err, open) => {
|
||||
const c = err instanceof BusReadError ? BUS_CODES[err.code] : undefined;
|
||||
return c ?? open;
|
||||
};
|
||||
if (step === 'confirmation') {
|
||||
if (Object.keys(value).length !== 1 || typeof value.choice !== 'string') return refuse('invalid-request');
|
||||
if (live() !== session) return refuse('sign-in-required');
|
||||
let v;
|
||||
try { v = await decide.call('decision.preview.console', { id }, { subject }); } catch (err) { return refuse(busCode(err, 'confirmation-unavailable')); }
|
||||
if (!preview(v, id)) return refuse('confirmation-unavailable');
|
||||
const action = value.choice === v.approve.key ? 'approve' : value.choice === v.deny.key ? 'deny' : null;
|
||||
if (!action) return refuse('decision-stale');
|
||||
if (live() !== session) return refuse('sign-in-required');
|
||||
const confirmation = confirmations.issue({ session, business: v.business, decision: id, choice: value.choice, digest: v.digest });
|
||||
if (!confirmation) return refuse('confirmation-unavailable');
|
||||
line(`action=${action}`);
|
||||
return json(res, 200, { id, question: typeof v.question === 'string' ? v.question : '', action, choice: value.choice, text: v[action].text, grant: v.grant, confirmation });
|
||||
}
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length !== 2 || !keys.includes('choice') || !keys.includes('confirmation') || typeof value.choice !== 'string') return refuse('invalid-request');
|
||||
// Consumed before any await on the bus, so a second click finds nothing (J12).
|
||||
const held = confirmations.consume(value.confirmation, { session, decision: id, choice: value.choice });
|
||||
if (!held) return refuse('confirmation-required');
|
||||
if (live() !== session) return refuse('sign-in-required');
|
||||
let r;
|
||||
try {
|
||||
r = await decide.call('decision.resolve.console', { id, choice: held.choice, digest: held.digest }, { subject, business: held.business });
|
||||
} catch (err) { return refuse(busCode(err, 'outcome-unknown')); }
|
||||
if (r?.id !== id || r.op !== 'resolved' || r.choice !== held.choice) return refuse('outcome-unknown');
|
||||
line(`choice=${JSON.stringify(held.choice.slice(0, 64))}${r.grant ? ` grant=${r.grant}` : ''}`);
|
||||
return json(res, 200, { id, op: 'resolved', choice: r.choice, ...(r.grant === 'started' ? { grant: 'started' } : {}) });
|
||||
}
|
||||
function json(res, status, body) {
|
||||
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
@@ -190,9 +190,8 @@
|
||||
out.kind = 'decision.raised'; out.decision = r.id; out.actor = { role: r.raised_by_role, run: r.raised_by_run };
|
||||
out.text = `Raised a ${r.class} decision for ${r.route_to}: ${String(r.question).split('\n')[0]}`;
|
||||
} else if (r.table === 'decision_events') {
|
||||
// The human answers from the CLI (via cli) or, since row 252 (#1753), a grant request from the
|
||||
// signed-in Console (via console); every other row is the broker's.
|
||||
out.kind = `decision.${r.op}`; out.decision = r.decision; out.by = r.via === 'cli' ? 'human' : r.via === 'console' ? 'human-console' : 'broker';
|
||||
// The human answers from the CLI (via cli); every other row is the broker's.
|
||||
out.kind = `decision.${r.op}`; out.decision = r.decision; out.by = r.via === 'cli' ? 'human' : 'broker';
|
||||
out.text = r.op === 'resolved' ? `${r.by} chose “${r.choice}” via ${r.via}` : r.op === 'seen' ? `${r.by} saw it${r.via ? ` via ${r.via}` : ''}` : `${r.op[0].toUpperCase()}${r.op.slice(1)} by ${r.by}${r.note ? `: ${r.note}` : ''}`;
|
||||
} else if (r.table === 'task_snapshots') {
|
||||
out.kind = `snapshot.${r.source}`;
|
||||
@@ -228,9 +227,8 @@
|
||||
// An empty state says what would appear and where it comes from.
|
||||
const empty = (what, from) => `<div class="empty"><b>${what}</b>${from ? `<p>${from}</p>` : ''}</div>`;
|
||||
const taskLink = ref => ref ? `<a href="#/tasks/${enc(ref)}">#${esc(String(ref).split('/')[1])}</a>` : '';
|
||||
// Rows without an actor role come from the human (CLI outside any run, or the signed-in Console)
|
||||
// or from the broker itself.
|
||||
const actor = (a, by) => `<span class="source">${a ? `${txt(a.role)} · ${txt(a.run)}` : by === 'human' ? 'human, CLI outside any run' : by === 'human-console' ? 'human, signed-in Console' : 'broker'}</span>`;
|
||||
// Rows without an actor role come from the human (CLI, outside any run) or from the broker itself.
|
||||
const actor = (a, by) => `<span class="source">${a ? `${txt(a.role)} · ${txt(a.run)}` : by === 'human' ? 'human, CLI outside any run' : 'broker'}</span>`;
|
||||
const PRIORITY = ['unset', 'low', 'medium', 'high', 'urgent', 'do now'];
|
||||
const head = (title, sub) => `<div class="page-head"><div><h1 tabindex="-1">${title}</h1>${sub ? `<p class="small muted" id="bus-status">${sub}</p>` : ''}</div></div>`;
|
||||
let readAt = null;
|
||||
@@ -258,7 +256,7 @@
|
||||
// Inbox
|
||||
function decisionCard(d) {
|
||||
return `<li class="wait-item s1-dec${d.blocking ? ' is-attn' : ''}"><div class="wait-head"><a href="#/inbox/${enc(d.id)}" class="s1-dec-open">${txt(first(d.question))}</a></div>
|
||||
<p class="s1-badges">${d.blocking ? blockChip('blocking') : ''}${chip(d.class, d.action)}${canDecide && d.console ? ` ${badge('Approve or Deny here', 'ok')}` : ''}</p>
|
||||
<p class="s1-badges">${d.blocking ? blockChip('blocking') : ''}${chip(d.class, d.action)}</p>
|
||||
<p class="small s1-auth">${approves(d.authorization)}</p>
|
||||
<p class="small muted">Raised by ${txt(d.raised_by_role)} ${when(d.at)}${d.task_ref ? ` · task ${taskLink(d.task_ref)}` : ''}${d.requirement_ref ? ` · ${txt(d.requirement_ref)}` : ''}</p></li>`;
|
||||
}
|
||||
@@ -316,101 +314,12 @@
|
||||
el.outerHTML = composerHtml();
|
||||
restore(k);
|
||||
}
|
||||
// Row 252 (#1753, design section 5): Approve and Deny for an open grant request, drawn only when
|
||||
// the server says this Console answers decisions (`decide` in /api/config: signed in through SSO
|
||||
// and started with --send) and the bus marked the inbox row eligible (its `console` field). The
|
||||
// first click asks the Console for a one-use confirmation that names what the answer does. Only
|
||||
// the second click answers. Nothing here retries: a refusal drops the confirmation, and an
|
||||
// unknown outcome says to refresh first. The state lives in `answer`, not the DOM, so the 10 s
|
||||
// refresh redraws it. The server and the bus check everything again; this is presentation only.
|
||||
let canDecide = false, ssoOn = false, answer = null;
|
||||
const ACTIONS = { approve: 'Approve', deny: 'Deny' };
|
||||
const grantText = g => [`method ${g.method}`, g.agent && `agent ${g.agent}`, g.host && `host ${g.host}`, Array.isArray(g.projects) && g.projects.length && `projects ${g.projects.join(', ')}`].filter(Boolean).map(s => txt(String(s))).join(' · ');
|
||||
function decideHtml() {
|
||||
const a = answer;
|
||||
if (!a) return '';
|
||||
const off = a.pending ? ' disabled' : '';
|
||||
let body;
|
||||
if (a.result) {
|
||||
const r = a.result;
|
||||
body = `<p class="receipt" id="decide-status" tabindex="-1">${r.action === 'approve' ? 'Approved' : 'Denied'}: you chose “${txt(r.choice)}”, ${txt(r.text)}. ${r.grant === 'started' ? 'The grant executor started.' : r.action === 'approve' ? 'The bus started no grant for it.' : 'Nothing starts for a denial.'} History below shows it once the bus is read again.</p>`;
|
||||
} else if (a.confirm) {
|
||||
const c = a.confirm, label = `Confirm ${c.action}`;
|
||||
body = `<div class="s1-confirm" role="group" aria-labelledby="b-confirm"><h3 id="b-confirm" class="small">${label}</h3>
|
||||
<dl class="kv"><dt>Id</dt><dd><code>${txt(c.id)}</code></dd><dt>Question</dt><dd class="s1-q">${txt(c.question)}</dd><dt>Answer</dt><dd><b class="s1-key">${txt(c.choice)}</b> ${txt(c.text)}</dd><dt>Grant</dt><dd>${grantText(c.grant)}</dd></dl>
|
||||
<p class="small muted">${c.action === 'approve' ? 'Approving starts this grant.' : 'Denying starts nothing.'} This confirmation works once and lasts two minutes.</p>
|
||||
<div class="s1-cmd"><button type="button" class="btn primary" data-decide="confirm"${off}>${a.pending ? 'Answering…' : label}</button><button type="button" class="btn" data-decide="cancel"${off}>Cancel</button></div></div>`;
|
||||
} else {
|
||||
body = `<ul class="s1-plain">${Object.entries(ACTIONS).map(([k, l]) => `<li><b>${l}</b> chooses <b class="s1-key">${txt(a.console[k].key)}</b> ${txt(a.console[k].text)}</li>`).join('')}</ul>
|
||||
<p class="small muted">Grant: ${grantText(a.console.grant)}. Either button opens a confirmation first. Nothing is answered until you confirm.</p>
|
||||
<div class="s1-cmd">${Object.entries(ACTIONS).map(([k, l]) => `<button type="button" class="btn${k === 'approve' ? ' primary' : ''}" data-decide="${k}"${off}>${a.pending === k ? 'Checking…' : l}</button>`).join('')}</div>`;
|
||||
}
|
||||
const e = a.error;
|
||||
const said = e ? `<p class="receipt failed" id="decide-status" tabindex="-1">Not answered here: ${txt(e.message)} (<code>${txt(e.code)}</code>).${e.code === 'outcome-unknown' ? ' Nothing was sent again.' : ''}</p>` : '';
|
||||
return `<section class="panel s1-decide" id="decide-section" aria-labelledby="b-decide"><h2 id="b-decide">Answer in Console</h2>${body}${said}</section>`;
|
||||
}
|
||||
// Redraw only the answer panel. `focus` names what takes focus after it.
|
||||
function paintDecide(focus) {
|
||||
const el = $('decide-section');
|
||||
if (!el) return;
|
||||
el.outerHTML = decideHtml();
|
||||
(focus ? $('decide-section')?.querySelector(focus) : null)?.focus();
|
||||
}
|
||||
async function decidePost(url, body) {
|
||||
let res = null, json = null;
|
||||
try {
|
||||
res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), cache: 'no-store', redirect: 'manual' });
|
||||
try { json = await res.json(); } catch { json = null; }
|
||||
} catch { res = null; }
|
||||
return { res, json };
|
||||
}
|
||||
// A refusal the Console wrote carries its code and message (design section 4). Anything else, a
|
||||
// 408 or no answer included, gets the fallback: at the resolve step that is outcome-unknown.
|
||||
const DECIDE_FALLBACK = {
|
||||
'confirmation-unavailable': 'Confirmation is unavailable. Try again later.',
|
||||
'outcome-unknown': 'The answer may have reached the bus. Refresh before trying again.',
|
||||
};
|
||||
const decideRefusal = (res, json, fallback) => res && !res.ok && res.status !== 408 && typeof json?.error === 'string' && typeof json?.message === 'string'
|
||||
? { code: json.error, message: json.message } : { code: fallback, message: DECIDE_FALLBACK[fallback] };
|
||||
async function decide(step) {
|
||||
const a = answer;
|
||||
if (!a || a.pending || a.result) return;
|
||||
if (step === 'cancel') { a.confirm = null; a.error = null; paintDecide('[data-decide="approve"]'); announce('Cancelled. Nothing was answered.'); return; }
|
||||
if (out()) { a.confirm = null; a.error = { code: 'sign-in-required', message: 'Sign in again before answering.' }; paintDecide('#decide-status'); return; }
|
||||
if (step === 'approve' || step === 'deny') {
|
||||
const choice = a.console[step].key;
|
||||
a.pending = step; a.error = null; a.confirm = null; paintDecide();
|
||||
const { res, json } = await decidePost(`/api/decisions/${enc(a.id)}/confirmation`, { choice });
|
||||
a.pending = null;
|
||||
// The page moved to another decision meanwhile: its confirmation is dropped, never shown.
|
||||
if (answer !== a) { if (res?.type === 'opaqueredirect') signOut(); return; }
|
||||
if (res?.type === 'opaqueredirect') { a.error = { code: 'sign-in-required', message: 'Sign in again before answering.' }; paintDecide('#decide-status'); signOut(); return; }
|
||||
if (res?.ok && json?.id === a.id && json.action === step && json.choice === choice && typeof json.confirmation === 'string' && json.grant && typeof json.grant.method === 'string') {
|
||||
a.confirm = json; paintDecide('[data-decide="confirm"]'); announce(`Confirm ${step}: ${json.text}`);
|
||||
} else { a.error = decideRefusal(res, json, 'confirmation-unavailable'); paintDecide('#decide-status'); announce(`Not answered: ${a.error.message}`); }
|
||||
return;
|
||||
}
|
||||
if (step !== 'confirm' || !a.confirm) return;
|
||||
const c = a.confirm;
|
||||
a.pending = 'confirm'; a.error = null; paintDecide();
|
||||
const { res, json } = await decidePost(`/api/decisions/${enc(a.id)}/resolve`, { choice: c.choice, confirmation: c.confirmation });
|
||||
// One use, whatever came back.
|
||||
a.pending = null; a.confirm = null;
|
||||
if (answer !== a) { if (res?.type === 'opaqueredirect') signOut(); else render(false); return; }
|
||||
if (res?.type === 'opaqueredirect') { a.error = { code: 'sign-in-required', message: 'Sign in again before answering. The sign-in proxy redirected the answer, so it never reached the Console.' }; paintDecide('#decide-status'); signOut(); return; }
|
||||
if (res?.ok && json?.id === a.id && json.op === 'resolved' && json.choice === c.choice) {
|
||||
a.result = { action: c.action, choice: c.choice, text: c.text, grant: json.grant ?? null };
|
||||
paintDecide('#decide-status'); announce(c.action === 'approve' ? 'Approved' : 'Denied'); render(false);
|
||||
} else { a.error = decideRefusal(res, json, 'outcome-unknown'); paintDecide('#decide-status'); announce(`Not answered: ${a.error.message}`); }
|
||||
}
|
||||
async function inboxView(get) {
|
||||
// The broker orders gated first, then oldest; lift blocking ones to the top.
|
||||
const rows = (await get('inbox')).slice().sort((a, b) => (b.blocking - a.blocking) || String(a.at).localeCompare(String(b.at)));
|
||||
countInbox(rows);
|
||||
const body = rows.length ? `<ul class="waiting s1-decs">${rows.map(decisionCard).join('')}</ul>` : empty('Nothing is waiting on you.', 'A decision shows here when an agent raises one routed to you through the bus.');
|
||||
const answered = canDecide ? 'A grant request can be approved or denied here, after a confirmation. Every other decision is answered in a terminal with <code>mosaic decide</code>.'
|
||||
: 'Decisions are answered in a terminal with <code>mosaic decide</code>; Console only shows them.';
|
||||
return `${head('Inbox', `${readNote()} ${answered}`)}
|
||||
return `${head('Inbox', `${readNote()} Decisions are answered in a terminal with <code>mosaic decide</code>; Console only shows them.`)}
|
||||
${await composer(get)}
|
||||
<h2 id="b-for-you">For you <span class="count">${rows.length}</span></h2>${body}
|
||||
<p class="small muted">Not shown here yet: ${gap('Decisions routed to roles')} ${gap('Closed decisions')} ${gap('Seen and DM state per row')} Open a decision for its seen, DM and closing history.</p>`;
|
||||
@@ -434,13 +343,6 @@
|
||||
// A command is offered only for an open decision routed to the human, and only when
|
||||
// the id and key pass the broker's identifier rule, so the copied text is safe to paste.
|
||||
const answerable = !c && d.routeTo === 'human';
|
||||
// Row 252: the answer panel follows this decision. A refresh keeps a confirmation open; a
|
||||
// decision no longer eligible (closed elsewhere, say) drops it, unless an answer is in flight
|
||||
// or this page just answered it.
|
||||
const eligible = canDecide && answerable && open?.console ? open.console : null;
|
||||
if (answer?.id !== d.id) answer = eligible ? { id: d.id, console: eligible, pending: null, confirm: null, error: null, result: null } : null;
|
||||
else if (eligible && !answer.pending && !answer.confirm) answer.console = eligible;
|
||||
else if (!eligible && !answer.pending && !answer.result) answer = null;
|
||||
const options = `<ol class="s1-opts">${d.options.map(o => {
|
||||
const cmd = `mosaic decide ${d.id} ${o.key}`, chosen = c?.choice === o.key, approving = d.authorization?.approvalChoice === o.key;
|
||||
const copy = !answerable ? '' : SAFE.test(String(d.id)) && SAFE.test(String(o.key))
|
||||
@@ -450,18 +352,11 @@
|
||||
}).join('')}</ol>`;
|
||||
const earlier = await chain(get, d);
|
||||
const history = [...d.seen.map(s => `<li>Seen by ${txt(s.by)}${s.via ? ` via ${txt(s.via)}` : ''} ${when(s.at)}</li>`), ...(c ? [`<li>${esc(c.op)} by ${txt(c.by)}${c.via ? ` via ${txt(c.via)}` : ''} ${when(c.at)}</li>`] : [])];
|
||||
// Without SSO, or under SSO without --send, the Console says why it has no buttons and never
|
||||
// draws a disabled one (design section 5).
|
||||
const why = !answerable || isTester || canDecide ? ''
|
||||
: ssoOn ? ' This Console is read-only for decisions: it was started without writes enabled (--send), so answers go through a terminal.'
|
||||
: ' Answering a grant request from the Console needs sign-in (--sso) and writes enabled (--send).';
|
||||
const help = eligible ? '<p class="small muted">Approve or Deny in the panel above, or run one of these in a terminal. Copy only puts the command on your clipboard.</p>'
|
||||
: answerable ? `<p class="small muted">To answer, run one of these in a terminal. Copy only puts the command on your clipboard. Console sends nothing.${why}</p>`
|
||||
const help = answerable ? '<p class="small muted">To answer, run one of these in a terminal. Copy only puts the command on your clipboard. Console sends nothing.</p>'
|
||||
: !c ? `<p class="small muted">This decision is routed to ${txt(d.routeTo)}, not to you, so Console offers no command for it.</p>` : '';
|
||||
return `${head(`Decision ${txt(short(d.id))}`, status)}
|
||||
<section aria-labelledby="b-q" class="panel"><h2 id="b-q">Question</h2><p class="s1-q">${txt(d.question)}</p>
|
||||
<dl class="kv"><dt>Class</dt><dd>${chip(d.class, d.action)}${d.blocking ? ` ${blockChip('blocking')}` : ''}</dd><dt>Approval</dt><dd class="s1-auth">${approves(d.authorization)}</dd><dt>Routed to</dt><dd>${txt(d.routeTo)}</dd><dt>Raised by</dt><dd>${txt(d.raisedBy.role)} <span class="source">${txt(d.raisedBy.run)}</span></dd><dt>Raised</dt><dd>${when(d.at)}</dd>${d.taskRef ? `<dt>Task</dt><dd>${taskLink(d.taskRef)} <span class="source">${txt(d.taskRef)}</span></dd>` : ''}${d.requirementRef ? `<dt>Requirement</dt><dd>${txt(d.requirementRef)}</dd>` : ''}<dt>DM</dt><dd>${d.dm ? `sent ${when(d.dm.at)}` : d.blocking ? 'not sent' : 'not needed (not blocking)'}</dd><dt>Id</dt><dd><code>${txt(d.id)}</code></dd></dl></section>
|
||||
${answer?.id === d.id ? decideHtml() : ''}
|
||||
<section aria-labelledby="b-o"><h2 id="b-o">Options</h2>${help}${options}<p class="small muted" id="copy-status"></p></section>
|
||||
<section aria-labelledby="b-c"><h2 id="b-c">Earlier versions</h2>${earlier.length ? `<ul class="s1-plain">${earlier.map(x => `<li><a href="#/inbox/${enc(x.id)}">${txt(short(x.id))}</a> ${badge(x.closed ? x.closed.op : 'open')} ${txt(first(x.question))}${x.closed?.note ? `<span class="source">${txt(x.closed.note)}</span>` : ''}</li>`).join('')}</ul>` : '<p class="muted">None.</p>'}<p class="small muted">${gap('A later version that replaces this one')}</p></section>
|
||||
<section aria-labelledby="b-h"><h2 id="b-h">History</h2>${history.length ? `<ul class="s1-plain">${history.join('')}</ul>` : '<p class="muted">Not seen yet.</p>'}<p><a href="#/trail/decision/${enc(d.id)}">Full trail for this decision</a></p></section>`;
|
||||
@@ -1203,7 +1098,6 @@
|
||||
return {
|
||||
href: view.contains(a) ? a.getAttribute('href') : null, copy: view.contains(a) ? a.dataset?.copy : null, retry: view.contains(a) && a.hasAttribute?.('data-retry'), mirror: view.contains(a) ? a.dataset?.mirror : null, h1: view.contains(a) && a.tagName === 'H1',
|
||||
field: view.contains(a) ? a.dataset?.field : null, caret: view.contains(a) && a.tagName === 'TEXTAREA' ? [a.selectionStart, a.selectionEnd, a.scrollTop] : null, send: view.contains(a) && a.hasAttribute?.('data-send'),
|
||||
decide: view.contains(a) ? a.dataset?.decide ?? (a.id === 'decide-status' ? 'status' : null) : null,
|
||||
open: [...view.querySelectorAll('details[id]')].filter(d => d.open).map(d => d.id), status: $('copy-status')?.textContent || '',
|
||||
};
|
||||
}
|
||||
@@ -1211,10 +1105,7 @@
|
||||
for (const id of k.open) { const d = $(id); if (d) d.open = true; }
|
||||
if ($('copy-status')) $('copy-status').textContent = k.status;
|
||||
const el = [...$('bus-view').querySelectorAll('a[href],[data-copy],[data-retry],[data-mirror],[data-field],[data-send]')].find(e => k.href ? e.getAttribute('href') === k.href : k.copy ? e.dataset.copy === k.copy : k.mirror ? e.dataset.mirror === k.mirror : k.field ? e.dataset.field === k.field : k.send ? e.hasAttribute('data-send') : k.retry && e.hasAttribute('data-retry'));
|
||||
// Row 252: the answer panel's buttons and its status line.
|
||||
const dec = k.decide ? $('bus-view').querySelector(k.decide === 'status' ? '#decide-status' : `[data-decide="${k.decide}"]`) : null;
|
||||
if (dec) dec.focus({ preventScroll: true });
|
||||
else if (el && (k.href || k.copy || k.retry || k.mirror || k.field || k.send)) {
|
||||
if (el && (k.href || k.copy || k.retry || k.mirror || k.field || k.send)) {
|
||||
el.focus({ preventScroll: true });
|
||||
if (k.caret && el.tagName === 'TEXTAREA') { el.setSelectionRange(k.caret[0], k.caret[1]); el.scrollTop = k.caret[2]; }
|
||||
}
|
||||
@@ -1306,9 +1197,6 @@
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Row 252: Approve, Deny, Confirm and Cancel. One click is one step; none repeats itself.
|
||||
const dec = e.target.closest('#decide-section [data-decide]');
|
||||
if (dec) { if (!dec.disabled) decide(dec.dataset.decide); return; }
|
||||
if (e.target.closest('#toast [data-dismiss]')) { toast(null); return; }
|
||||
const item = e.target.closest('#cmdk-list [data-href]');
|
||||
if (item) { pkGo(item.dataset.href); return; }
|
||||
@@ -1430,14 +1318,12 @@
|
||||
isTester = c?.tester === true;
|
||||
canEdit = !isTester && c?.edit === true;
|
||||
canSend = !isTester && c?.send === true;
|
||||
ssoOn = c?.sso === true;
|
||||
canDecide = !isTester && c?.decide === true;
|
||||
if (isTester) {
|
||||
const banner = document.createElement('div'); banner.id = 'tester-banner'; banner.className = 'banner ref';
|
||||
banner.setAttribute('role', 'status'); banner.textContent = 'Tester Console. Changes are recorded as tester, never as the human.';
|
||||
$('main').prepend(banner);
|
||||
}
|
||||
if (!canSend && !isTester && !ssoOn) return;
|
||||
if (!canSend && !isTester) return;
|
||||
const path = routeOf().split('?')[0];
|
||||
if (path.startsWith('/chat')||path.startsWith('/inbox')||path==='/settings'||path.startsWith('/docs/')) render(false); else if (path === '/' || path === '') paintHeld(heldErr);
|
||||
}).catch(() => {});
|
||||
|
||||
@@ -10,7 +10,6 @@ const busStatus = code => ['tester-off', 'tester-required'].includes(code) ? 403
|
||||
import { ReadError, readStatus, fieldTable } from './reads.mjs';
|
||||
import { chatHTTP } from './chat-http.mjs';
|
||||
import { processingHTTP } from './processing-http.mjs';
|
||||
import { DECISION_ROUTE, decisionHTTP, decisionRefuse, createConfirmations } from './decision-http.mjs';
|
||||
|
||||
export const DEFAULT_BOARD = 'http://127.0.0.1:7331';
|
||||
export function isLoopback(host) {
|
||||
@@ -580,7 +579,7 @@ async function projectFileRead(res, read, path, search) {
|
||||
// `log` takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
|
||||
// tests; the field table is otherwise the committed packages/tasks/fields.json.
|
||||
// `agentWrite` (row 194, D7) is `(verb, args) => Promise` for the Agents section's verbs and instance.request.
|
||||
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, processing = null, decisionWrite = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
|
||||
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, processing = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
|
||||
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
|
||||
if (host === 'localhost') host = '127.0.0.1';
|
||||
if (tester && preview !== null) throw new Error('tester listener cannot use preview mode');
|
||||
@@ -591,9 +590,6 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (tester && [send, settingsWrite, projectSave, assistantStop].some(value => value !== null)) throw new Error('tester listener permits only instance, route, name and chat writes');
|
||||
if (tester && !testerSocket) throw new Error('tester listener requires private socket');
|
||||
if (!tester && testerSocket) throw new Error('tester socket requires tester listener');
|
||||
// Row 252 (#1753, design section 4): Console decision answers need --sso and --send, never the tester.
|
||||
if (decisionWrite !== null && (tester || sso === null || send === null || typeof decisionWrite !== 'function')) throw new Error('Console decision answers need --sso and --send on the main listener');
|
||||
const confirmations = createConfirmations();
|
||||
const upstream = boardURL(board);
|
||||
// An object with none of the three is refused too: asking for the preview never serves without it.
|
||||
if (preview !== null && !(preview = previewOptions({ publicHost: preview.host, authHeader: preview.header, allowUser: preview.user }))) {
|
||||
@@ -645,21 +641,18 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
// alone stays open on a loopback Host for the status and release gates (Q-C).
|
||||
if (sso !== null && !named && !(path === '/healthz' && req.method === 'GET')) return json(res, 403, { error: 'public Host required' });
|
||||
// No CORS. JSON content type and same-origin checks keep browser forms out.
|
||||
const decisionPath = DECISION_ROUTE.test(path);
|
||||
if (req.headers.origin && req.headers.origin !== (named ? (sso ? sso.publicOrigin : `https://${preview.host}`) : `http://${req.headers.host}`))
|
||||
return decisionPath ? decisionRefuse(res, 'cross-origin') : json(res, 403, { error: 'cross-origin request refused' });
|
||||
let signer = preview, ssoSession = null;
|
||||
if (req.headers.origin && req.headers.origin !== (named ? (sso ? sso.publicOrigin : `https://${preview.host}`) : `http://${req.headers.host}`)) return json(res, 403, { error: 'cross-origin request refused' });
|
||||
let signer = preview;
|
||||
if (sso !== null && named) {
|
||||
if (path.startsWith('/auth/')) return sso.handle(req, res, path, search);
|
||||
// Only a page load or a write keeps a session alive; the page's background reads don't (Q-D).
|
||||
const session = path === '/healthz' ? null : sso.session(req, { touch: req.method === 'POST' || (req.method === 'GET' && !path.startsWith('/api/')) });
|
||||
if (!session && path !== '/healthz') {
|
||||
if (decisionPath) return decisionRefuse(res, 'sign-in-required');
|
||||
if (path.startsWith('/api/') || req.method !== 'GET') return json(res, 401, { error: 'sign-in required' });
|
||||
res.writeHead(303, { location: `/auth/login?return=${encodeURIComponent(path + search)}`, 'cache-control': 'no-store' });
|
||||
return res.end();
|
||||
}
|
||||
if (session) { signer = { sso: true, user: session.name, host: sso.publicHost, origin: sso.publicOrigin }; ssoSession = session; }
|
||||
if (session) signer = { sso: true, user: session.name, host: sso.publicHost, origin: sso.publicOrigin };
|
||||
}
|
||||
if (named && preview !== null) {
|
||||
// One value, equal to the user id; the proxy strips client copies and sets it after sign-in.
|
||||
@@ -667,13 +660,6 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (!values || values.length !== 1 || values[0] !== preview.user) return json(res, 401, { error: 'sign-in required' });
|
||||
}
|
||||
const writer = tester ? { tester: true, user: 'tester', origin: TESTER_ORIGIN } : signer;
|
||||
// Row 252: Approve and Deny. The tester listener never offers them. Anywhere else without a signed-in
|
||||
// session on the public name under --sso and --send, they are read-only.
|
||||
if (decisionPath) {
|
||||
if (tester) return decisionRefuse(res, 'not-found');
|
||||
const decide = decisionWrite && send && named && sso ? { call: decisionWrite, origin: sso.publicOrigin } : null;
|
||||
return decisionHTTP(req, res, { path, decide, session: ssoSession, live: () => sso.session(req), confirmations, log });
|
||||
}
|
||||
// Row 194 (#1692, D4.1): the tester also launches and terminates.
|
||||
if (tester && !['GET', 'HEAD'].includes(req.method) && !['/api/instances', '/api/routes', '/api/name', '/api/agent/launch', '/api/agent/terminate'].includes(path) && !TESTER_CHAT_WRITE.test(path))
|
||||
return json(res, 403, { error: 'read-only', sent: false });
|
||||
@@ -777,7 +763,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'method not allowed' });
|
||||
// `send`: the page shows the composer only where a send can pass (the public name, --send on).
|
||||
// `edit`: the editor shows Save only where a save can pass, the same test as the route's.
|
||||
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), send: !tester && Boolean(send) && named, decide: !tester && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !tester && Boolean(send) && Boolean(projectSave) && named });
|
||||
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), send: !tester && Boolean(send) && named, edit: !tester && Boolean(send) && Boolean(projectSave) && named });
|
||||
if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) });
|
||||
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
|
||||
const file = files.get(path);
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
// Row 252 (#1753, design section 5, J16 and J17): Approve and Deny on a decision page, in a browser.
|
||||
// A loopback page has no sign-in, so a script added before the page loads sets `sso` and `decide`
|
||||
// in /api/config from the page URL, and answers each POST /api/decisions/... from a queue, recording
|
||||
// what the page sent. Server-side checks are decision-http.test.mjs's and the bus side is
|
||||
// packages/bus/tests/console-decide.test.mjs's. The decisions come from an in-process broker
|
||||
// through its reader session. Refresh timers fire after 150 ms.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Store } from '../../bus/src/store.mjs';
|
||||
import { Broker } from '../../bus/src/broker.mjs';
|
||||
import { views } from '../../bus/src/views.mjs';
|
||||
import { grantDecision } from '../../bus/tests/host-fixtures.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { fixture, close } from './fixture.mjs';
|
||||
|
||||
const HOSTILE = '<script>window.injected=1</script><img src=x onerror="window.injected=2">';
|
||||
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' }, roles: { chief: { authority: { withinRole: ['message.send'], crossRole: [] } }, pm: { authority: { withinRole: ['message.send'], crossRole: [] } } } } };
|
||||
|
||||
// Answers in order; `hold` waits for window.__release(), `reject` is a network failure, `redirect`
|
||||
// what fetch gives for the sign-in proxy's redirect under redirect: 'manual'.
|
||||
const STUB = `(() => {
|
||||
const real = window.fetch.bind(window);
|
||||
window.__posts = []; window.__answers = [];
|
||||
const timeout = window.setTimeout.bind(window);
|
||||
window.setTimeout = (f, ms, ...rest) => timeout(f, ms === 10000 ? 150 : ms, ...rest);
|
||||
const q = new URLSearchParams(location.search);
|
||||
const answer = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
||||
window.fetch = async (url, init = {}) => {
|
||||
const u = String(url);
|
||||
if (u === '/api/config') {
|
||||
const c = await (await real(url, init)).json();
|
||||
return answer(200, { ...c, ...(q.has('sso') ? { sso: true } : {}), ...(q.has('send') ? { send: true } : {}), ...(q.has('decide') ? { send: true, decide: true } : {}), ...(q.has('tester') ? { tester: true } : {}) });
|
||||
}
|
||||
if (!u.startsWith('/api/decisions/')) return real(url, init);
|
||||
window.__posts.push({ url: u, method: init.method, headers: init.headers, body: init.body, cache: init.cache, redirect: init.redirect });
|
||||
const a = window.__answers.shift();
|
||||
if (!a) throw new TypeError('no answer queued');
|
||||
if (a.hold) await new Promise(r => { window.__release = r; });
|
||||
if (a.reject) throw new TypeError('Failed to fetch');
|
||||
if (a.redirect) return { type: 'opaqueredirect', ok: false, status: 0, json: async () => { throw new SyntaxError('no body'); } };
|
||||
return answer(a.status, a.body);
|
||||
};
|
||||
})();`;
|
||||
|
||||
async function setup() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'webui-decide-browser-'));
|
||||
const store = new Store(root), b0 = new Broker({ store, businesses });
|
||||
const grant = { method: 'host-enroll', host: 'web1', address: '192.0.2.10', port: 22, user: 'jarvis', sudo: true, fingerprints: [] };
|
||||
const open = grantDecision(store, { grant, close: null });
|
||||
store.run('UPDATE decisions SET question=? WHERE id=?', `Enroll web1? ${HOSTILE}`, open);
|
||||
const deploy = grantDecision(store, { action: 'deploy', grant, close: null });
|
||||
const bare = grantDecision(store, { close: null });
|
||||
const reader = b0.bindReader({ business: 'demo' });
|
||||
const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) });
|
||||
const f = await fixture(), b = await browser();
|
||||
const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`;
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB });
|
||||
await b.viewport(1280);
|
||||
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
|
||||
const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`);
|
||||
const has = sel => b.evaluate(`!!document.querySelector(${JSON.stringify(sel)})`);
|
||||
const posts = () => b.evaluate('window.__posts');
|
||||
const queue = a => b.evaluate(`window.__answers.push(${JSON.stringify(a)}), true`);
|
||||
const click = step => b.evaluate(`document.querySelector('#decide-section [data-decide="${step}"]').click(), true`);
|
||||
const page = async (query, id) => {
|
||||
await b.navigate(`${base}?${query}#/inbox/${id}`);
|
||||
await wait('document.querySelector("#bus-view h1")?.textContent.startsWith("Decision") && !document.querySelector("#bus-view [aria-busy]")');
|
||||
// The config read redraws once it lands; a refresh tick after it settles the page.
|
||||
await new Promise(r => setTimeout(r, 400));
|
||||
};
|
||||
const settle = () => new Promise(r => setTimeout(r, 600));
|
||||
const done = async () => { await b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); };
|
||||
return { b, store, base, open, deploy, bare, wait, text, has, posts, queue, click, page, settle, done };
|
||||
}
|
||||
|
||||
const CONFIRM = (s, action, choice, text) => ({ status: 200, body: { id: s.open, question: `Enroll web1? ${HOSTILE}`, action, choice, text, grant: { method: 'host-enroll', host: 'web1' }, confirmation: 'c'.repeat(64) } });
|
||||
|
||||
test('J16: no controls without sso and decide, read-only reasons, terminal lines kept, and only an eligible grant gets the panel', { timeout: 120000 }, async () => {
|
||||
const s = await setup();
|
||||
try {
|
||||
// Without SSO: no panel, no button, and the reason names both flags.
|
||||
await s.page('x', s.open);
|
||||
assert.equal(await s.has('#decide-section'), false);
|
||||
assert.equal(await s.has('[data-decide]'), false);
|
||||
assert.match(await s.text('#bus-view'), /needs sign-in \(--sso\) and writes enabled \(--send\)/);
|
||||
assert.equal(await s.b.evaluate('document.querySelectorAll("#bus-view [data-copy]").length'), 2);
|
||||
// SSO without --send: read-only, still the terminal lines.
|
||||
await s.page('sso', s.open);
|
||||
assert.equal(await s.has('#decide-section'), false);
|
||||
assert.match(await s.text('#bus-view'), /read-only for decisions: it was started without writes enabled \(--send\)/);
|
||||
// send alone, without decide, still draws nothing.
|
||||
await s.page('sso&send', s.open);
|
||||
assert.equal(await s.has('#decide-section'), false);
|
||||
// A tester Console never answers, whatever the config says.
|
||||
await s.page('sso&decide&tester', s.open);
|
||||
assert.equal(await s.has('#decide-section'), false);
|
||||
// Eligible: the panel, both buttons, the escaped question and the terminal lines too.
|
||||
await s.page('sso&decide', s.open);
|
||||
assert.equal(await s.text('#decide-section h2'), 'Answer in Console');
|
||||
assert.deepEqual(await s.b.evaluate('[...document.querySelectorAll("#decide-section [data-decide]")].map(e => [e.dataset.decide, e.textContent, e.disabled])'), [['approve', 'Approve', false], ['deny', 'Deny', false]]);
|
||||
assert.match(await s.text('#decide-section'), /Grant: method host-enroll · host web1\./);
|
||||
assert.equal(await s.b.evaluate('document.querySelectorAll("#bus-view [data-copy]").length'), 2);
|
||||
assert.equal(await s.b.evaluate('window.injected ?? null'), null);
|
||||
assert.ok((await s.text('#bus-view .s1-q')).includes(HOSTILE));
|
||||
// A grant raise with another action, and a credential.mint with no grant, keep the terminal only.
|
||||
for (const id of [s.deploy, s.bare]) {
|
||||
await s.page('sso&decide', id);
|
||||
assert.equal(await s.has('#decide-section'), false);
|
||||
assert.equal(await s.has('[data-decide]'), false);
|
||||
}
|
||||
// The Inbox says so, and marks only the eligible row.
|
||||
await s.b.navigate(`${s.base}?sso&decide#/inbox`);
|
||||
await s.wait('document.querySelector("#bus-view h1")?.textContent.startsWith("Inbox") && document.querySelectorAll("#bus-view .s1-decs > li").length === 3');
|
||||
await s.settle();
|
||||
assert.match(await s.text('#bus-view'), /A grant request can be approved or denied here, after a confirmation\./);
|
||||
assert.equal(await s.b.evaluate('[...document.querySelectorAll("#bus-view .s1-decs > li")].filter(li => li.textContent.includes("Approve or Deny here")).length'), 1);
|
||||
assert.deepEqual(await s.posts(), []);
|
||||
} finally { await s.done(); }
|
||||
});
|
||||
|
||||
test('J17: one POST per click, confirm then answer, cancel, refusals and an unknown outcome never retry', { timeout: 120000 }, async () => {
|
||||
const s = await setup();
|
||||
const enc = encodeURIComponent;
|
||||
try {
|
||||
await s.page('sso&decide', s.open);
|
||||
|
||||
// Approve asks for a confirmation and answers nothing. The button holds while the request is out.
|
||||
await s.queue({ ...CONFIRM(s, 'approve', 'yes', 'Yes'), hold: true });
|
||||
await s.click('approve');
|
||||
await s.wait('document.querySelector("#decide-section [data-decide=approve]")?.textContent === "Checking…"');
|
||||
assert.equal(await s.b.evaluate('[...document.querySelectorAll("#decide-section [data-decide]")].every(e => e.disabled)'), true);
|
||||
await s.click('approve');
|
||||
await s.b.evaluate('window.__release(), true');
|
||||
await s.wait('document.querySelector("#decide-section [data-decide=confirm]")');
|
||||
let sent = await s.posts();
|
||||
assert.equal(sent.length, 1);
|
||||
assert.deepEqual(sent[0], { url: `/api/decisions/${enc(s.open)}/confirmation`, method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ choice: 'yes' }), cache: 'no-store', redirect: 'manual' });
|
||||
assert.equal(await s.text('#decide-section [data-decide=confirm]'), 'Confirm approve');
|
||||
assert.match(await s.text('#decide-section .s1-confirm'), /Approving starts this grant\./);
|
||||
assert.equal(await s.b.evaluate('document.activeElement?.dataset.decide ?? null'), 'confirm');
|
||||
assert.equal(await s.b.evaluate('window.injected ?? null'), null);
|
||||
|
||||
// A refresh keeps the confirmation open.
|
||||
await s.settle();
|
||||
assert.equal(await s.has('#decide-section [data-decide=confirm]'), true);
|
||||
|
||||
// Cancel drops it without a request.
|
||||
await s.click('cancel');
|
||||
await s.wait('document.querySelector("#decide-section [data-decide=approve]")');
|
||||
assert.equal((await s.posts()).length, 1);
|
||||
|
||||
// A refusal the Console wrote shows its code and message; a 408 or a lost answer is unavailable.
|
||||
await s.queue({ status: 409, body: { error: 'decision-stale', message: 'The choices changed. Review the decision again.' } });
|
||||
await s.click('deny');
|
||||
await s.wait('document.querySelector("#decide-status")');
|
||||
assert.equal(await s.text('#decide-status'), 'Not answered here: The choices changed. Review the decision again. (decision-stale).');
|
||||
await s.queue({ status: 408, body: { error: 'x', message: 'y' } });
|
||||
await s.click('deny');
|
||||
await s.wait('document.querySelector("#decide-status code")?.textContent === "confirmation-unavailable"');
|
||||
await s.queue({ reject: true });
|
||||
await s.click('deny');
|
||||
await s.wait('document.querySelectorAll("#decide-section [data-decide]:not([disabled])").length === 2');
|
||||
assert.equal(await s.b.evaluate('document.querySelector("#decide-status code")?.textContent'), 'confirmation-unavailable');
|
||||
assert.equal((await s.posts()).length, 4);
|
||||
|
||||
// A reply that doesn't match what was asked is not a confirmation.
|
||||
await s.queue({ status: 200, body: { ...CONFIRM(s, 'approve', 'yes', 'Yes').body } });
|
||||
await s.click('deny');
|
||||
await s.wait('document.querySelector("#decide-status code")?.textContent === "confirmation-unavailable" && document.querySelectorAll("#decide-section [data-decide]:not([disabled])").length === 2');
|
||||
assert.equal(await s.has('#decide-section [data-decide=confirm]'), false);
|
||||
|
||||
// An unknown outcome at the answer says to refresh, drops the confirmation and sends nothing again.
|
||||
await s.queue(CONFIRM(s, 'deny', 'no', 'No'));
|
||||
await s.click('deny');
|
||||
await s.wait('document.querySelector("#decide-section [data-decide=confirm]")');
|
||||
assert.match(await s.text('#decide-section .s1-confirm'), /Denying starts nothing\./);
|
||||
await s.queue({ status: 503, body: { error: 'outcome-unknown', message: 'The answer may have reached the bus. Refresh before trying again.' } });
|
||||
await s.click('confirm');
|
||||
await s.wait('document.querySelector("#decide-status code")?.textContent === "outcome-unknown"');
|
||||
assert.match(await s.text('#decide-status'), /Nothing was sent again\.$/);
|
||||
sent = await s.posts();
|
||||
assert.equal(sent.length, 7);
|
||||
assert.deepEqual(sent[6], { url: `/api/decisions/${enc(s.open)}/resolve`, method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ choice: 'no', confirmation: 'c'.repeat(64) }), cache: 'no-store', redirect: 'manual' });
|
||||
assert.equal(await s.has('#decide-section [data-decide=confirm]'), false);
|
||||
await s.settle();
|
||||
await s.settle();
|
||||
assert.equal((await s.posts()).length, 7, 'the page sent again by itself');
|
||||
|
||||
// A confirmed approve: one answer, a receipt, and no buttons after it, across refreshes.
|
||||
await s.queue(CONFIRM(s, 'approve', 'yes', 'Yes'));
|
||||
await s.click('approve');
|
||||
await s.wait('document.querySelector("#decide-section [data-decide=confirm]")');
|
||||
await s.queue({ status: 200, body: { id: s.open, op: 'resolved', choice: 'yes', grant: 'started' } });
|
||||
await s.click('confirm');
|
||||
await s.wait('document.querySelector("#decide-status")?.textContent.startsWith("Approved")');
|
||||
assert.equal(await s.text('#decide-status'), 'Approved: you chose “yes”, Yes. The grant executor started. History below shows it once the bus is read again.');
|
||||
await s.settle();
|
||||
assert.equal(await s.has('[data-decide]'), false);
|
||||
assert.equal((await s.posts()).length, 9);
|
||||
|
||||
// Closed elsewhere: the next read drops the panel and shows the recorded answer with its provenance.
|
||||
s.store.run('INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)', s.open, '2026-10-22T00:00:00.000Z', 'resolved', 'jason', 'yes', 'console');
|
||||
await s.page('sso&decide', s.open);
|
||||
assert.equal(await s.has('#decide-section'), false);
|
||||
assert.match(await s.text('#bus-view'), /Resolved: jason chose “yes” via console/);
|
||||
assert.equal(await s.b.evaluate('document.querySelectorAll("#bus-view [data-copy]").length'), 0);
|
||||
} finally { await s.done(); }
|
||||
});
|
||||
|
||||
test('a sign-in redirect at either step signs the page out and answers nothing', { timeout: 120000 }, async () => {
|
||||
const s = await setup();
|
||||
try {
|
||||
await s.page('sso&decide', s.open);
|
||||
await s.queue({ redirect: true });
|
||||
await s.click('approve');
|
||||
await s.wait('document.body.classList.contains("signed-out")');
|
||||
assert.equal((await s.posts()).length, 1);
|
||||
await s.page('sso&decide', s.open);
|
||||
await s.queue(CONFIRM(s, 'approve', 'yes', 'Yes'));
|
||||
await s.click('approve');
|
||||
await s.wait('document.querySelector("#decide-section [data-decide=confirm]")');
|
||||
await s.queue({ redirect: true });
|
||||
await s.click('confirm');
|
||||
await s.wait('document.body.classList.contains("signed-out")');
|
||||
assert.equal((await s.posts()).length, 2);
|
||||
} finally { await s.done(); }
|
||||
});
|
||||
@@ -1,154 +0,0 @@
|
||||
// Row 252 (#1753, design sections 4 and 5): the Console decision routes on the human listener, signed
|
||||
// in through the fake OP in helpers/fake-oidc.mjs on loopback. The bus transport is a recording stub,
|
||||
// so this covers the listener's gates and the session it passes; decision-http.test.mjs covers the
|
||||
// endpoint's own table and console-decide.test.mjs the bus. The public name is console.example.test,
|
||||
// sent only as a Host header to the test's own listener. sso.test.mjs pins its own log lines, so the
|
||||
// decide lines are checked here.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { request } from 'node:http';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createSso, SESSION_COOKIE } from '../src/sso.mjs';
|
||||
import { DECISION_REFUSALS } from '../src/decision-http.mjs';
|
||||
import { consoleCall } from '../src/bus.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
import { fakeOidc } from './helpers/fake-oidc.mjs';
|
||||
|
||||
const HOST = 'console.example.test', ORIGIN = `https://${HOST}`;
|
||||
const DIGEST = 'b'.repeat(64);
|
||||
const PREVIEW = { id: 'd1', business: 'demo', question: 'Enroll web1?', digest: DIGEST, approve: { key: 'yes', text: 'Yes' }, deny: { key: 'no', text: 'No' }, grant: { method: 'host-enroll', host: 'web1' } };
|
||||
|
||||
// node:http, not fetch: fetch can't set Host, Origin or Sec-Fetch-Site.
|
||||
function call(port, { method = 'GET', path = '/', headers = {}, body, host = HOST } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const length = body === undefined ? {} : { 'content-length': Buffer.byteLength(body) };
|
||||
const req = request({ host: '127.0.0.1', port, method, path, headers: { host, ...length, ...headers } }, res => {
|
||||
let text = '';
|
||||
res.on('data', c => (text += c));
|
||||
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: text && (res.headers['content-type'] ?? '').startsWith('application/json') ? JSON.parse(text) : text }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end(body);
|
||||
});
|
||||
}
|
||||
const writeHeaders = cookie => ({ ...(cookie ? { cookie } : {}), origin: ORIGIN, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' });
|
||||
|
||||
// The fake OP, createSso over it, and the human listener. `server` overrides startServer options.
|
||||
// The decision transport records each call and answers a preview or a resolve.
|
||||
async function setup(t, server = {}) {
|
||||
const op = await fakeOidc();
|
||||
t.after(() => op.close());
|
||||
const iss = op.issuer('a');
|
||||
const config = { publicOrigin: ORIGIN, providers: [{ id: 'a', issuer: iss.issuer, clientId: iss.clientId, clientSecret: { slot: 'a' }, algs: ['RS256'], subjects: [{ sub: 'user-a', name: 'person-a' }] }] };
|
||||
const lines = [];
|
||||
const sso = await createSso(config, { secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log: l => lines.push(l) });
|
||||
t.after(() => sso.close());
|
||||
const calls = [];
|
||||
const decisionWrite = async (verb, args, ctx) => {
|
||||
calls.push({ verb, args, ctx });
|
||||
return verb === 'decision.preview.console' ? { ...PREVIEW } : { id: args.id, op: 'resolved', choice: args.choice };
|
||||
};
|
||||
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async () => []]));
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus, sso, send: async () => ({ id: 'msg-0001' }), decisionWrite, log: l => lines.push(l), ...server });
|
||||
t.after(() => close(web));
|
||||
const port = web.address().port;
|
||||
const signIn = async () => {
|
||||
const start = await call(port, { path: '/auth/login' });
|
||||
assert.equal(start.status, 303);
|
||||
const cookie = [start.headers['set-cookie']].flat()[0].split(';')[0];
|
||||
const g = op.grant(iss, start.headers.location);
|
||||
const q = new URLSearchParams({ code: g.code, state: new URL(start.headers.location).searchParams.get('state') });
|
||||
const r = await call(port, { path: `/auth/callback?${q}`, headers: { cookie } });
|
||||
return [r.headers['set-cookie'] ?? []].flat().find(c => c.startsWith(`${SESSION_COOKIE}=`) && !c.includes('Max-Age=0')).split(';')[0];
|
||||
};
|
||||
const post = (cookie, path, body, headers = {}) => call(port, { method: 'POST', path, headers: { ...writeHeaders(cookie), ...headers }, body: JSON.stringify(body) });
|
||||
return { port, iss, calls, lines, signIn, post };
|
||||
}
|
||||
const refused = (r, code) => {
|
||||
assert.equal(r.status, DECISION_REFUSALS[code][0], `${code}: ${r.status} ${JSON.stringify(r.body)}`);
|
||||
assert.deepEqual(r.body, { error: code, message: DECISION_REFUSALS[code][1] });
|
||||
};
|
||||
|
||||
test('a signed-in owner confirms and answers, with the session\'s provider, issuer and sub as the subject', async t => {
|
||||
const env = await setup(t);
|
||||
const session = await env.signIn();
|
||||
const config = await call(env.port, { path: '/api/config', headers: { cookie: session } });
|
||||
assert.equal(config.body.decide, true);
|
||||
assert.equal(config.body.sso, true);
|
||||
const c = await env.post(session, '/api/decisions/d1/confirmation', { choice: 'yes' });
|
||||
assert.equal(c.status, 200, JSON.stringify(c.body));
|
||||
const r = await env.post(session, '/api/decisions/d1/resolve', { choice: 'yes', confirmation: c.body.confirmation });
|
||||
assert.equal(r.status, 200, JSON.stringify(r.body));
|
||||
assert.deepEqual(r.body, { id: 'd1', op: 'resolved', choice: 'yes' });
|
||||
const subject = { provider: 'a', iss: env.iss.issuer, sub: 'user-a' };
|
||||
assert.deepEqual(env.calls, [
|
||||
{ verb: 'decision.preview.console', args: { id: 'd1' }, ctx: { subject } },
|
||||
{ verb: 'decision.resolve.console', args: { id: 'd1', choice: 'yes', digest: DIGEST }, ctx: { subject, business: 'demo' } },
|
||||
]);
|
||||
assert.match(env.lines.at(-1), / decide user="person-a" decision="d1" step=resolve choice="yes"$/);
|
||||
assert.equal(env.lines.some(l => l.includes(c.body.confirmation) || l.includes(session.split('=')[1])), false);
|
||||
});
|
||||
|
||||
test('the listener gates: no session, a cross-origin page, a loopback Host, a GET, and a sign-out between the steps', async t => {
|
||||
const env = await setup(t);
|
||||
refused(await env.post(null, '/api/decisions/d1/confirmation', { choice: 'yes' }), 'sign-in-required');
|
||||
const session = await env.signIn();
|
||||
refused(await env.post(session, '/api/decisions/d1/confirmation', { choice: 'yes' }, { origin: 'https://evil.test' }), 'cross-origin');
|
||||
refused(await env.post(session, '/api/decisions/d1/confirmation', { choice: 'yes' }, { 'sec-fetch-site': 'cross-site' }), 'cross-origin');
|
||||
const loop = await call(env.port, { method: 'POST', path: '/api/decisions/d1/confirmation', host: `127.0.0.1:${env.port}`, headers: { cookie: session, 'content-type': 'application/json' }, body: '{"choice":"yes"}' });
|
||||
assert.equal(loop.status, 403);
|
||||
refused(await call(env.port, { path: '/api/decisions/d1/confirmation', headers: { cookie: session } }), 'method-not-allowed');
|
||||
assert.equal(env.calls.length, 0);
|
||||
const c = await env.post(session, '/api/decisions/d1/confirmation', { choice: 'yes' });
|
||||
assert.equal(c.status, 200);
|
||||
const out = await call(env.port, { method: 'POST', path: '/auth/logout', headers: { cookie: session, origin: ORIGIN, 'sec-fetch-site': 'same-origin' } });
|
||||
assert.equal(out.status, 303);
|
||||
refused(await env.post(session, '/api/decisions/d1/resolve', { choice: 'yes', confirmation: c.body.confirmation }), 'sign-in-required');
|
||||
assert.equal(env.calls.length, 1);
|
||||
// A new session can't use the old session's confirmation.
|
||||
const again = await env.signIn();
|
||||
refused(await env.post(again, '/api/decisions/d1/resolve', { choice: 'yes', confirmation: c.body.confirmation }), 'confirmation-required');
|
||||
assert.equal(env.calls.length, 1);
|
||||
});
|
||||
|
||||
test('J16: without --send or a decision transport the routes are read-only and /api/config says no; startServer refuses decide without sso or send', async t => {
|
||||
for (const server of [{ send: null }, { decisionWrite: null }]) {
|
||||
const env = await setup(t, server);
|
||||
const session = await env.signIn();
|
||||
const config = await call(env.port, { path: '/api/config', headers: { cookie: session } });
|
||||
assert.equal(config.body.decide, false);
|
||||
assert.equal(config.body.sso, true);
|
||||
refused(await env.post(session, '/api/decisions/d1/confirmation', { choice: 'yes' }), 'read-only');
|
||||
assert.equal(env.calls.length, 0);
|
||||
}
|
||||
const write = async () => ({});
|
||||
await assert.rejects(startServer({ port: 0, decisionWrite: write, send: async () => ({}) }), /need --sso and --send/);
|
||||
await assert.rejects(startServer({ port: 0, tester: true, testerSocket: {}, decisionWrite: write }), /need --sso and --send/);
|
||||
const plain = await startServer({ port: 0, board: 'http://127.0.0.1:9' });
|
||||
t.after(() => close(plain));
|
||||
const port = plain.address().port, host = `127.0.0.1:${port}`;
|
||||
const r = await call(port, { method: 'POST', path: '/api/decisions/d1/confirmation', host, headers: { origin: `http://${host}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }, body: '{"choice":"yes"}' });
|
||||
refused(r, 'read-only');
|
||||
const config = await call(port, { path: '/api/config', host });
|
||||
assert.equal(config.body.decide, false);
|
||||
assert.equal('sso' in config.body, false);
|
||||
});
|
||||
|
||||
test('N4: decision.resolve has no Console route, the --send caller names no decision verb, and consoleCall takes only the two console verbs', async t => {
|
||||
const env = await setup(t);
|
||||
const session = await env.signIn();
|
||||
for (const verb of ['decision.resolve', 'decision.resolve.console', 'decision.preview.console']) {
|
||||
for (const method of ['GET', 'POST']) {
|
||||
const r = await call(env.port, { method, path: `/api/bus/${verb}`, headers: writeHeaders(session), ...(method === 'POST' ? { body: '{}' } : {}) });
|
||||
assert.equal(r.status, 404, `${method} ${verb}`);
|
||||
}
|
||||
}
|
||||
assert.equal(env.calls.length, 0);
|
||||
const cli = readFileSync(new URL('../src/cli.mjs', import.meta.url), 'utf8');
|
||||
assert.doesNotMatch(cli, /['"]decision\./);
|
||||
const c = consoleCall({ dataRoot: '/nonexistent', socket: '/nonexistent', business: 'demo', cli: '/nonexistent/console-cli.mjs', timeoutMs: 1000 });
|
||||
for (const verb of ['decision.resolve', 'decision.seen', 'message.send', 'inbox']) {
|
||||
await assert.rejects(c(verb, { id: 'd1', choice: 'yes' }, { subject: { provider: 'a', iss: 'i', sub: 's' } }), /the Console does not call/);
|
||||
}
|
||||
});
|
||||
@@ -1,205 +0,0 @@
|
||||
// Row 252 (#1753): the Console decision endpoint, driven with a fake request, response and bus
|
||||
// transport. The real bus side is in packages/bus/tests/console-decide.test.mjs and the page side
|
||||
// in the browser tests.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { BusReadError } from '../src/bus.mjs';
|
||||
import { DECISION_REFUSALS, createConfirmations, decisionHTTP } from '../src/decision-http.mjs';
|
||||
|
||||
const ORIGIN = 'https://console.test';
|
||||
const DIGEST = 'a'.repeat(64);
|
||||
const PREVIEW = {
|
||||
id: 'd1', business: 'demo', question: 'Enroll host?', digest: DIGEST,
|
||||
approve: { key: 'yes', text: 'Yes, enroll it' }, deny: { key: 'no', text: 'No' },
|
||||
grant: { method: 'host-enroll', host: 'h1' },
|
||||
};
|
||||
|
||||
function request({ method = 'POST', body = {}, headers = {}, raw } = {}) {
|
||||
const h = { origin: [ORIGIN], 'sec-fetch-site': ['same-origin'], 'content-type': ['application/json'], ...headers };
|
||||
for (const k of Object.keys(h)) if (h[k] === null) delete h[k];
|
||||
const data = Buffer.from(raw ?? JSON.stringify(body));
|
||||
return { method, headersDistinct: h, async *[Symbol.asyncIterator]() { yield data; } };
|
||||
}
|
||||
function response() {
|
||||
const r = { status: null, headers: null, text: '' };
|
||||
r.writeHead = (s, h) => { r.status = s; r.headers = h; };
|
||||
r.end = (t = '') => { r.text += t; };
|
||||
r.json = () => JSON.parse(r.text);
|
||||
return r;
|
||||
}
|
||||
function setup({ answer = () => ({ ...PREVIEW }), resolve = (a) => ({ id: a.id, op: 'resolved', choice: a.choice, grant: 'started' }), now } = {}) {
|
||||
const session = { name: 'jason', provider: 'fake-oidc', iss: 'https://idp.test', sub: 'owner-1' };
|
||||
const calls = [], lines = [];
|
||||
const s = {
|
||||
session, calls, lines, current: session,
|
||||
confirmations: createConfirmations(now ? { now } : {}),
|
||||
decide: {
|
||||
origin: ORIGIN,
|
||||
call: async (verb, args, ctx) => {
|
||||
calls.push({ verb, args, ctx });
|
||||
return verb === 'decision.preview.console' ? answer(args) : resolve(args);
|
||||
},
|
||||
},
|
||||
};
|
||||
s.run = async (path, req) => {
|
||||
const res = response();
|
||||
await decisionHTTP(req, res, { path, decide: s.decide, session: s.session, live: () => s.current, confirmations: s.confirmations, log: (l) => lines.push(l) });
|
||||
return res;
|
||||
};
|
||||
s.confirm = (choice = 'yes', id = 'd1') => s.run(`/api/decisions/${id}/confirmation`, request({ body: { choice } }));
|
||||
s.resolve = (body, id = 'd1') => s.run(`/api/decisions/${id}/resolve`, request({ body }));
|
||||
return s;
|
||||
}
|
||||
const refused = (res, code) => {
|
||||
assert.equal(res.status, DECISION_REFUSALS[code][0], `${code}: ${res.text}`);
|
||||
assert.deepEqual(res.json(), { error: code, message: DECISION_REFUSALS[code][1] });
|
||||
assert.equal(res.headers['cache-control'], 'no-store');
|
||||
};
|
||||
|
||||
test('a confirmation then a resolve answers once with the held choice and digest, and a second resolve refuses', async () => {
|
||||
const s = setup();
|
||||
const c = await s.confirm('yes');
|
||||
assert.equal(c.status, 200);
|
||||
const body = c.json();
|
||||
assert.match(body.confirmation, /^[a-f0-9]{64}$/);
|
||||
assert.deepEqual({ ...body, confirmation: 'x' }, { id: 'd1', question: 'Enroll host?', action: 'approve', choice: 'yes', text: 'Yes, enroll it', grant: PREVIEW.grant, confirmation: 'x' });
|
||||
assert.deepEqual(s.calls[0], { verb: 'decision.preview.console', args: { id: 'd1' }, ctx: { subject: { provider: 'fake-oidc', iss: 'https://idp.test', sub: 'owner-1' } } });
|
||||
const r = await s.resolve({ choice: 'yes', confirmation: body.confirmation });
|
||||
assert.equal(r.status, 200);
|
||||
assert.deepEqual(r.json(), { id: 'd1', op: 'resolved', choice: 'yes', grant: 'started' });
|
||||
assert.deepEqual(s.calls[1], { verb: 'decision.resolve.console', args: { id: 'd1', choice: 'yes', digest: DIGEST }, ctx: { subject: { provider: 'fake-oidc', iss: 'https://idp.test', sub: 'owner-1' }, business: 'demo' } });
|
||||
// J12: the confirmation is spent, so a repeat never reaches the bus.
|
||||
refused(await s.resolve({ choice: 'yes', confirmation: body.confirmation }), 'confirmation-required');
|
||||
assert.equal(s.calls.length, 2);
|
||||
assert.equal(s.lines.length, 3);
|
||||
assert.match(s.lines[0], / decide user="jason" decision="d1" step=confirmation action=approve$/);
|
||||
assert.match(s.lines[1], / step=resolve choice="yes" grant=started$/);
|
||||
assert.doesNotMatch(s.lines.join('\n'), new RegExp(body.confirmation));
|
||||
});
|
||||
|
||||
test('deny resolves without a grant field', async () => {
|
||||
const s = setup({ resolve: (a) => ({ id: a.id, op: 'resolved', choice: a.choice }) });
|
||||
const c = (await s.confirm('no')).json();
|
||||
assert.equal(c.action, 'deny');
|
||||
assert.equal(c.text, 'No');
|
||||
const r = await s.resolve({ choice: 'no', confirmation: c.confirmation });
|
||||
assert.deepEqual(r.json(), { id: 'd1', op: 'resolved', choice: 'no' });
|
||||
});
|
||||
|
||||
test('the gates before the body: method, read-only, sign-in and same origin', async () => {
|
||||
const s = setup();
|
||||
refused(await s.run('/api/decisions/d1/confirmation', request({ method: 'GET' })), 'method-not-allowed');
|
||||
const res = response();
|
||||
await decisionHTTP(request({ body: { choice: 'yes' } }), res, { path: '/api/decisions/d1/confirmation', decide: null, session: s.session, live: () => s.session, confirmations: s.confirmations, log: () => {} });
|
||||
refused(res, 'read-only');
|
||||
const out = response();
|
||||
await decisionHTTP(request({ body: { choice: 'yes' } }), out, { path: '/api/decisions/d1/confirmation', decide: s.decide, session: null, live: () => null, confirmations: s.confirmations, log: () => {} });
|
||||
refused(out, 'sign-in-required');
|
||||
for (const headers of [{ origin: ['https://evil.test'] }, { origin: null }, { origin: [ORIGIN, ORIGIN] }, { 'sec-fetch-site': ['same-site'] }, { 'sec-fetch-site': null }]) {
|
||||
refused(await s.run('/api/decisions/d1/confirmation', request({ body: { choice: 'yes' }, headers })), 'cross-origin');
|
||||
}
|
||||
assert.equal(s.calls.length, 0);
|
||||
});
|
||||
|
||||
test('a bad body, id or content type refuses before the bus', async () => {
|
||||
const s = setup();
|
||||
const path = '/api/decisions/d1/confirmation';
|
||||
refused(await s.run(path, request({ body: { choice: 'yes' }, headers: { 'content-type': ['text/plain'] } })), 'invalid-request');
|
||||
refused(await s.run(path, request({ raw: '{' })), 'invalid-request');
|
||||
refused(await s.run(path, request({ raw: '[]' })), 'invalid-request');
|
||||
refused(await s.run(path, request({ body: { choice: 'yes', extra: 1 } })), 'invalid-request');
|
||||
refused(await s.run(path, request({ body: { choice: 1 } })), 'invalid-request');
|
||||
refused(await s.run(path, request({ raw: JSON.stringify({ choice: 'y'.repeat(5000) }) })), 'request-too-large');
|
||||
refused(await s.run('/api/decisions/..%2Fx/confirmation', request({ body: { choice: 'yes' } })), 'decision-not-found');
|
||||
refused(await s.run('/api/decisions/%E0%A4%A/confirmation', request({ body: { choice: 'yes' } })), 'decision-not-found');
|
||||
refused(await s.resolve({ choice: 'yes' }), 'invalid-request');
|
||||
refused(await s.resolve({ choice: 'yes', confirmation: 'a'.repeat(64), more: 1 }), 'invalid-request');
|
||||
assert.equal(s.calls.length, 0);
|
||||
});
|
||||
|
||||
test('a choice that is neither key is decision-stale, and a malformed preview is confirmation-unavailable', async () => {
|
||||
refused(await setup().confirm('maybe'), 'decision-stale');
|
||||
for (const bad of [{ ...PREVIEW, id: 'd2' }, { ...PREVIEW, digest: 'short' }, { ...PREVIEW, deny: { key: 'yes', text: 'No' } }, { ...PREVIEW, grant: null }, null]) {
|
||||
const s = setup({ answer: () => bad });
|
||||
refused(await s.confirm('yes'), 'confirmation-unavailable');
|
||||
assert.equal(s.confirmations.size, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('bus refusals map by code, and an unknown or missing code is unavailable at preview and outcome-unknown at resolve', async () => {
|
||||
const cases = [
|
||||
['human-required', 'human-required'], ['agent-required', 'human-required'], ['access-revoked', 'human-required'],
|
||||
['peercred-required', 'peercred-required'], ['decision-not-eligible', 'decision-not-eligible'],
|
||||
['decision-closed', 'decision-closed'], ['decision-stale', 'decision-stale'], ['invalid-choice', 'decision-stale'],
|
||||
['decision-not-found', 'decision-not-found'], ['outcome-unknown', 'confirmation-unavailable'], ['weird', 'confirmation-unavailable'],
|
||||
];
|
||||
for (const [bus, code] of cases) {
|
||||
const s = setup({ answer: () => { throw new BusReadError(bus); } });
|
||||
refused(await s.confirm('yes'), code);
|
||||
}
|
||||
refused(await setup({ answer: () => { throw new Error('boom'); } }).confirm('yes'), 'confirmation-unavailable');
|
||||
for (const [bus, code] of [['decision-closed', 'decision-closed'], ['outcome-unknown', 'outcome-unknown'], ['weird', 'outcome-unknown']]) {
|
||||
const s = setup({ resolve: () => { throw new BusReadError(bus); } });
|
||||
const c = (await s.confirm('yes')).json();
|
||||
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), code);
|
||||
// Nothing retries, and the confirmation stays spent.
|
||||
assert.equal(s.calls.length, 2);
|
||||
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), 'confirmation-required');
|
||||
}
|
||||
for (const reply of [null, { id: 'd2', op: 'resolved', choice: 'yes' }, { id: 'd1', op: 'refused', choice: 'yes' }, { id: 'd1', op: 'resolved', choice: 'no' }]) {
|
||||
const s = setup({ resolve: () => reply });
|
||||
const c = (await s.confirm('yes')).json();
|
||||
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), 'outcome-unknown');
|
||||
}
|
||||
});
|
||||
|
||||
test('a confirmation binds the session, decision and choice, and any mismatch spends it', async () => {
|
||||
const s = setup();
|
||||
let c = (await s.confirm('yes')).json();
|
||||
refused(await s.resolve({ choice: 'no', confirmation: c.confirmation }), 'confirmation-required');
|
||||
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), 'confirmation-required');
|
||||
c = (await s.confirm('yes')).json();
|
||||
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }, 'd2'), 'confirmation-required');
|
||||
c = (await s.confirm('yes')).json();
|
||||
const other = setup();
|
||||
other.confirmations = s.confirmations;
|
||||
other.session = { ...s.session };
|
||||
other.current = other.session;
|
||||
refused(await other.resolve({ choice: 'yes', confirmation: c.confirmation }), 'confirmation-required');
|
||||
refused(await s.resolve({ choice: 'yes', confirmation: 'not-a-token' }), 'confirmation-required');
|
||||
assert.equal(s.calls.filter((x) => x.verb === 'decision.resolve.console').length, 0);
|
||||
assert.equal(other.calls.length, 0);
|
||||
});
|
||||
|
||||
test('a session that ends between the steps refuses before the bus', async () => {
|
||||
const s = setup();
|
||||
s.current = null;
|
||||
refused(await s.confirm('yes'), 'sign-in-required');
|
||||
assert.equal(s.calls.length, 0);
|
||||
s.current = s.session;
|
||||
const c = (await s.confirm('yes')).json();
|
||||
s.current = { ...s.session };
|
||||
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), 'sign-in-required');
|
||||
assert.equal(s.calls.length, 1);
|
||||
// A sign-out during the preview also refuses, and no confirmation is issued.
|
||||
const t = setup({ answer: () => { t.current = null; return { ...PREVIEW }; } });
|
||||
refused(await t.confirm('yes'), 'sign-in-required');
|
||||
assert.equal(t.confirmations.size, 0);
|
||||
});
|
||||
|
||||
test('confirmations expire after the TTL, cap at the limit without dropping a live one, and are one use', () => {
|
||||
let t = 0;
|
||||
const store = createConfirmations({ ttlMs: 1000, max: 2, now: () => t });
|
||||
const bind = { session: 's', business: 'demo', decision: 'd1', choice: 'yes', digest: DIGEST };
|
||||
const a = store.issue(bind), b = store.issue(bind);
|
||||
assert.equal(store.issue(bind), null);
|
||||
assert.equal(store.size, 2);
|
||||
assert.equal(store.consume(a, bind).digest, DIGEST);
|
||||
assert.equal(store.consume(a, bind), null);
|
||||
t = 1000;
|
||||
assert.equal(store.consume(b, bind), null);
|
||||
assert.equal(store.size, 0);
|
||||
assert.match(store.issue(bind), /^[a-f0-9]{64}$/);
|
||||
assert.equal(store.consume(undefined, bind), null);
|
||||
assert.equal(store.consume('A'.repeat(64), bind), null);
|
||||
});
|
||||
@@ -50,18 +50,14 @@ test('shipped strings are unchanged, and the web keeps its write boundary', () =
|
||||
['bus.js', 'No open tasks.'], ['bus.js', 'No role is held right now.'], ['bus.js', 'Nothing of this kind in the trail.'],
|
||||
['bus.js', 'Console shows nothing rather than a guess. Nothing was written.'],
|
||||
]) assert.equal(plain[file].split(s).length - 1, n, `${file}: ${s}`);
|
||||
// Row 252 (#1753, mos-1321): the one decision control is the Console's grant Approve and Deny, one
|
||||
// fetch(url, ...) in decidePost, called only for a decision's confirmation and its resolve. Its labels
|
||||
// come from ACTIONS, and the doesNotMatch below keeps any literal Resolve, Decide or Approve label out
|
||||
// of the markup. The board's Seen and reply are app.js's POSTs; bus.js's other explicit writes are
|
||||
// No Resolve control. The board's Seen and reply are app.js's POSTs; bus.js has four explicit writes:
|
||||
// row 76's message to a role, row 122's human Settings update, row 125's token-limit reset (one path,
|
||||
// to the same Settings route) and row 148's spec and docs save. Row 165 (#1662) adds the role
|
||||
// instance add or change to /api/instances, and row 65 the routing table's save to /api/routes.
|
||||
assert.doesNotMatch(src, /<button[^>]*>[^<]*(Resolve|Decide|Approve)/i);
|
||||
const writes = read('bus.js').toString().match(/fetch\([^;]*method:\s*['"](POST|PUT|PATCH|DELETE)[^;]*;/g) ?? [];
|
||||
assert.deepEqual(writes, ["fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), cache: 'no-store', redirect: 'manual' });", "fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision,reset:[JSON.parse(path)]}),cache:'no-store',redirect:'manual'});", "fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/project-file', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(sent), cache: 'no-store', redirect: 'manual' });", "fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });"]);
|
||||
assert.equal(read('bus.js').toString().split(/method:/).length - 1, 8);
|
||||
assert.deepEqual([...read('bus.js').toString().matchAll(/decidePost\(([^,]*),/g)].map(m => m[1]), ['url', '`/api/decisions/${enc(a.id)}/confirmation`', '`/api/decisions/${enc(a.id)}/resolve`']);
|
||||
assert.deepEqual(writes, ["fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision,reset:[JSON.parse(path)]}),cache:'no-store',redirect:'manual'});", "fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/project-file', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(sent), cache: 'no-store', redirect: 'manual' });", "fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });"]);
|
||||
assert.equal(read('bus.js').toString().split(/method:/).length - 1, 7);
|
||||
// Row 155 (#1649): the one fetch(url, ...) posts only to these three routes, each a literal.
|
||||
// Row 176 (#1674): the tester name form adds a second /api/name caller.
|
||||
assert.deepEqual([...read('bus.js').toString().matchAll(/assistantPost\(([^,]*),/g)].map(m => m[1]), ['url', "'/api/settings'", "'/api/name'", "'/api/assistant-stop'", "'/api/name'"]);
|
||||
|
||||
@@ -183,14 +183,3 @@ test('row 177 W4: the main listener refuses x-mosaic-tester on chat reads and wr
|
||||
assert.equal((await http(web, '/api/chat/threads', { headers: signed })).status, 200);
|
||||
assert.deepEqual(calls, ['threads']);
|
||||
});
|
||||
|
||||
test('Row 252: the tester listener has no decision answer routes, and its config never offers them', async t => {
|
||||
const s = await testerFixture(t);
|
||||
for (const step of ['confirmation', 'resolve']) {
|
||||
const r = await post(s.web, `/api/decisions/d1/${step}`, step === 'confirmation' ? { choice: 'yes' } : { choice: 'yes', confirmation: 'a'.repeat(64) });
|
||||
assert.equal(r.status, 404, r.text);
|
||||
assert.equal(r.body.error, 'not-found');
|
||||
}
|
||||
assert.equal((await http(s.web, '/api/config')).body.decide, false);
|
||||
assert.deepEqual(s.calls, []);
|
||||
});
|
||||
Reference in new issue
Block a user