Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a131e9567 | ||
|
|
66f7becf21 |
No files matched your search
@@ -5411,7 +5411,3 @@ Row 299 (573, #1806, Gander, Filbert): before, the P10 band test timed a fault r
|
||||
2026-10-23T17:29:46Z | Gyro Vikunja571 sage-3542 Connections amendment | Branch gyro/editor-connections-design cut exactly from landed PRD1.17 30e6bb680; read REQ-LIB-5 in that PRD. Replaced stale J-b waits in parent and W4. Proposed W8 owns current authorized Markdown/DOCX links/backlinks list, labels, versioned refresh, leakage refusals and fixture tests. Build order under mos-1470/comment961 is W7 closed routing candidate, W8 landing, then W7 human first-real-import opening. No REQ-LIB-8 condition or acceptance change. Q234/mos-1363/S0 M1-M14 and post-10-27/MVP priority fixed. Static diff, twelve brief sections and append-only checks pass. No repo Node, tests, build, browser, queue mutation or live action. Fenton reviews after authorized docs branch push; Sage lands on approval.
|
||||
|
||||
2026-10-23T17:32:37Z | Gyro sage-3544 fenton-0016 N1/N2 delta | Before: W7 Gate/final real-conversion list omit the W8 build-order cross-reference and W8 human-view filtering cites agent settings. After: both locations explicitly put W8 landing before human opening as mos-1470 build order, not acceptance; comment/tracked-deletion links are not current story links and never appear in the human Connections view regardless of agent settings. New commit atop approved 5af060fb5, no amend. Static diff and append-only checks pass. Docs only, no repo Node/tests/live action. Fenton checks only this delta.
|
||||
|
||||
2026-10-23 | Boyd | Row 298 (#1804, Q228), before implementation completion | Took over Megavolt 7a141269c on boyd/row298 at 45dca69f6 under mos-1496/1502. Approved D1-D6 govern human-only grants.scope.set, empty fallback, in-scope close/evidence and spend recheck. All inherited code UNTESTED; no Node/tests until Sage CLEAR. Plan and static gaps recorded in agents/boyd/work/queue-298/BUILD.md; no live grant or credential access.
|
||||
|
||||
2026-10-23 | Boyd | Row 298 (#1804, Q228), targeted verification | On sage-3642 T1 lane B, five whole files serial: business scope 7/0, bus scope 14/0, scope runtime 1/0, CLI scope 4/0, project runtime 3/0 (29/0, no skips). Initial bus fixture typo recorded and corrected. M1-M10 killed by named assertions, source restored and final files green. Dewey dewey-0933 positive control and notes addressed; manifest and evidence in agents/boyd/work/queue-298/BUILD.md. Full package/script gate remains Sage's. No live scope set.
|
||||
@@ -1,233 +0,0 @@
|
||||
# Row 298 (#1804, Q228): chief-of-staff grant scope
|
||||
|
||||
Owner Boyd (48a3ae38), reviewer Dewey. Branch `boyd/row298`, base `45dca69f6`.
|
||||
Assignment: mos-1496/Q245. Handoff: Megavolt megavolt-0203, local `7a141269c`;
|
||||
cherry-picked as `4de3cec27` (design) and `7cea739de` (UNTESTED code).
|
||||
Brief: `docs/plans/2026-10-23_mvp-readiness-rows.md`, chief-of-staff grant scope.
|
||||
Megavolt's original design remains in `agents/megavolt/work/queue-298/BUILD.md`.
|
||||
|
||||
## Authority and accepted design
|
||||
|
||||
Sage sage-3594, restated in sage-3612: launch.grantScope, lenient read with empty
|
||||
fallback, strict human-only write. No general organization setter may write it.
|
||||
Per-method agents/projects or agents/hosts/commands; host-enroll never in scope.
|
||||
Host command arrays match whole tokens and exact lengths. `grants.scope.set`
|
||||
from the human CLI is Approve; no Console page or chief-raised scope change.
|
||||
Mos mos-1496/1502 requires the same human-shell guard as `mosaic access`.
|
||||
The broker closes an in-scope request via scope and writes grant.scope.used;
|
||||
missing evidence must refuse execution. Narrowing before spend fails
|
||||
scope-changed. Empty scope ships; only Jason sets the live value (Q188).
|
||||
|
||||
No credential files read, no live grants, no deployment, no queue writes. Push
|
||||
only boyd/*, additive commits, no amend/force-push or attribution trailers.
|
||||
Sage owns queue, shared test slot and landing. No Node/tests before CLEAR.
|
||||
|
||||
## Implementation plan and ledger
|
||||
|
||||
Using writing-plans and executing-plans inline; the approved brief and D1-D6
|
||||
are the spec. This packet is the requested plan/tracking location.
|
||||
|
||||
- [x] Read brief, roster, implementation and handoff; register session.
|
||||
- [x] Bring Megavolt's two commits onto Boyd's branch without changing history.
|
||||
- [x] Add independent CLI tests in `packages/cli/tests/grants-scope.test.mjs`:
|
||||
scope read/set requests; agent-marker rejection before stdin/transport;
|
||||
--tester rejection; missing revision, malformed/empty/oversize input.
|
||||
- [x] Strengthen new business/bus grant-scope tests for exact commands,
|
||||
scope evidence binding, host-enroll exclusion and spent scope-changed.
|
||||
- [ ] On Sage CLEAR, observe targeted failures before fixes; fix inherited
|
||||
implementation using the existing human proof and grant executors.
|
||||
- [ ] Update bus/CLI docs, append BUILD-LOG before/after evidence, manifest.
|
||||
- [ ] On Sage CLEAR run touched suites; record exact results and mutants.
|
||||
- [ ] Commit UNTESTED until verified; push boyd/row298 only when slot permits;
|
||||
report to Sage and Dewey, cc Mos for pushes/blockers. Dewey reviews manifest.
|
||||
|
||||
Pre-flight interfaces: business checkGrantScope/grantScopeOf/grantInScope feed
|
||||
the instance writer and broker. Runtime supplies a fresh scope from the org
|
||||
file and routes the human setter to the same organization writer. CLI callFor
|
||||
supplies refuseInsideAgent and humanTransport. The grant executors consume the
|
||||
broker's immutable close/evidence. Tests use scratch files/fake Gitea only.
|
||||
|
||||
Review focus: exact argv length, stale scope before spend, forged/missing scope
|
||||
evidence, malformed fallback, and CLI agent ancestry. Tests must cover each.
|
||||
|
||||
Static findings before any Boyd Node/test run:
|
||||
|
||||
- Handoff introduced commandPrefixes beyond the accepted D2 shape, allowing
|
||||
trailing args. Remove it; only exact command arrays are authorized.
|
||||
- scope.used carries a digest without validating it, and no org revision.
|
||||
Bind evidence to the canonical scope snapshot and grant/close sequence;
|
||||
preserve historical grants when scope is later changed, recheck at spend.
|
||||
- Gitea recheck throws inside the spend transaction and rolls back spend on
|
||||
scope-changed. Preserve spend and log failure for that case (D6).
|
||||
- The optional giteaStart?. assertion always passes when the API is absent;
|
||||
use the real runGiteaGrant entry point. Fake Gitea tokens() does exist.
|
||||
|
||||
## Existing test changes (mos-1272)
|
||||
|
||||
Approved by Mos mos-1510 via Sage sage-3624; edited exactly as approved:
|
||||
|
||||
| File | Actual numstat | Reason |
|
||||
| --- | --- | --- |
|
||||
| packages/bus/tests/project-runtime.test.mjs | +1 / -1 | Vocabulary import pin adds grantScopeOf and grantInScope. |
|
||||
|
||||
Other test work is confined to row 298's new files. No pre-existing CLI tests
|
||||
will change. Record final numstat here before review.
|
||||
|
||||
## Human proof and organization ownership
|
||||
|
||||
CLI: `packages/cli/src/cli.mjs` callFor runs refuseInsideAgent before constructing
|
||||
humanTransport, as `accessCommand` does. The setter takes no --tester flag.
|
||||
`packages/bus/src/human.mjs` verifyHuman checks the nonce, CLI path and every
|
||||
ancestor's markers/argv; `bindHuman` and #human in broker.mjs require the
|
||||
business human, via cli, outsideAgent true. Tests will pin this chain's caller
|
||||
refusal. The scope receipt records by/via/outsideAgent and old/new sha256.
|
||||
|
||||
Org file: `packages/business/src/organization.mjs` regular() requires a regular,
|
||||
non-symlink file owned by the process uid and not writable by group/other;
|
||||
write() uses revision checks, locked durable receipts and atomic rename.
|
||||
The OS owner uid can edit it directly; the agent sandbox does not mount the
|
||||
user's config (`packages/seat/src/sandbox.mjs`). This is the existing cooperative
|
||||
human/process boundary, not a hostile same-uid isolation claim.
|
||||
|
||||
Chief enrollment: broker instances.set calls #human and #mayHuman before
|
||||
add/change; credential issue/delete use #requestCredential's human gate.
|
||||
launch.by is an organization-file setting, excluded from general setters;
|
||||
only that chief may auto-approve within scope. Additional chief instances
|
||||
remain on the per-grant human approval path.
|
||||
|
||||
## Validation
|
||||
|
||||
UNTESTED. No Boyd Node/test run yet; waiting for Sage CLEAR.
|
||||
M1: bypass in-scope check. M2: bypass invalid-value fallback. M3: remove human
|
||||
setter guard. M4: drop grant.scope.used. All need named failing tests on gate.
|
||||
|
||||
## Session correction
|
||||
|
||||
The initial SESSIONS entry labels startup 19:13Z; startup was approximately
|
||||
19:08Z. Message header timestamps in boyd-0001 through 0004 were estimates;
|
||||
use T3's receipt timestamps as authoritative. Subsequent reports use date -u.
|
||||
|
||||
## Departures from Megavolt 7a141269c (Sage sage-3624)
|
||||
|
||||
1. Removed commandPrefixes; host scope commands match exact token arrays only.
|
||||
2. grantScope callback now supplies one organization read `{value,revision}`.
|
||||
grant.scope.used includes that revision, canonical scope snapshot and digest;
|
||||
#approvedGrant validates the digest, shape, method, agent and close sequence.
|
||||
It uses the immutable snapshot so later narrowing doesn't revoke issued grants.
|
||||
3. Gitea scope-changed is caught inside the spend transaction and logs a terminal
|
||||
failure without rolling back its spend. Other recheck failures keep their
|
||||
existing behavior. Host already catches recheck failures after spending.
|
||||
|
||||
Additional handoff corrections: replaced optional nonexistent giteaStart with
|
||||
runGiteaGrant; added successful host executor/revoke and enrollment-exclusion
|
||||
coverage; added real runtime writer coverage and independent CLI guard tests.
|
||||
|
||||
Sage scheduled push at 19:41Z with MOSAIC_GITEA_CREDENTIAL_FILE unset, then T1
|
||||
lane B around 19:47Z (after explicit CLEAR), four new files whole and serial,
|
||||
hard stop 20:00Z. Fethry lane A is disjoint. No test was run early. Production
|
||||
corrections are marked UNTESTED under that slot mandate; baseline regression
|
||||
checks against the inherited implementation will run only on CLEAR.
|
||||
|
||||
## Candidate preparation (before T1)
|
||||
|
||||
The three static corrections, CLI/runtime coverage and docs are prepared but
|
||||
UNTESTED. `git diff --check` is clean. The approved import-pin edit is exactly
|
||||
1 addition / 1 deletion. M1-M4 are encoded in mutants.py (not run). No Node,
|
||||
https git, full suite or webui build has run in Boyd's worktree.
|
||||
|
||||
Review package: product/test/documentation files in MANIFEST.sha256, excluding
|
||||
append-only project logs and the preserved predecessor packet. The manifest
|
||||
identifies exact bytes for Dewey; each later product change regenerates it and
|
||||
requires a review delta. Existing tests changed: only project-runtime, 1/1.
|
||||
|
||||
## Dewey static review dewey-0933
|
||||
|
||||
N1: Added a positive control to the malformed-evidence fixture. A valid event
|
||||
must pass scope authorization and reach decision-mismatch on the deliberately
|
||||
absent definition binding; invalid events remain not-authorized. No spend.
|
||||
N2: Additional named mutants cover spend recheck, exact argv length, Gitea spend
|
||||
rollback, launch.by eligibility and accepting any evidence row (results below).
|
||||
N3: At spend only grant scope membership is rechecked, not whether the raising
|
||||
chief is still launch.by. This implements sage-3594 Q5; changing the launcher
|
||||
later does not by itself invalidate a pending scope-approved request.
|
||||
N4: The scope shortcut returns the closed decision view to the chief without
|
||||
sending a separate close message; documented in the bus verb table.
|
||||
N5: grant.scope is logged after the organization file commit in a separate bus
|
||||
transaction. A crash between them leaves durable changes.jsonl as the receipt,
|
||||
as with other organization writers. The setter does not claim cross-store
|
||||
atomicity.
|
||||
|
||||
## T1 lane B results (sage-3642, 2026-10-23 19:23Z–19:29Z)
|
||||
|
||||
TMPDIR: `/mnt/storage/scratch/tmp/boyd298-4bzr6fn7`. Each whole file ran serially
|
||||
as `timeout -k 10 180 node --test --test-concurrency=1 FILE`, TMPDIR set to that
|
||||
scratch directory. Final restored candidate results:
|
||||
|
||||
| Whole file | Pass | Fail | Skip |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| business/tests/grant-scope.test.mjs | 7 | 0 | 0 |
|
||||
| bus/tests/grant-scope.test.mjs | 14 | 0 | 0 |
|
||||
| bus/tests/grant-scope-runtime.test.mjs | 1 | 0 | 0 |
|
||||
| cli/tests/grants-scope.test.mjs | 4 | 0 | 0 |
|
||||
| bus/tests/project-runtime.test.mjs | 3 | 0 | 0 |
|
||||
|
||||
29/0/0 total. `final-1.log` through `final-5.log` hold the final output. The
|
||||
runtime's reload-needs-restart notice is expected: the fixture's boot document
|
||||
has fewer credential references than its organization document, as in the
|
||||
existing instances runtime tests. No credential contents are loaded from the
|
||||
real user config.
|
||||
|
||||
Initial run: business 7/0; bus 13/1 due to Boyd's test typo, an out-of-scope
|
||||
`opts` reference in the missing-scope assertion. Corrected only that new-test
|
||||
line, then bus 14/0 and the remaining files green. Dewey N1 positive control
|
||||
was added and the whole bus file passed again; final rerun above followed all
|
||||
mutants. No production code changed during this test window.
|
||||
|
||||
Mutants: all whole files, serial, with timeout -k 10 and the same TMPDIR.
|
||||
`mutants.py --sage-clear sage-3642 --logs SCRATCH/review-mutants --deadline
|
||||
2026-10-23T19:38:00+00:00` restores exact original source after each run.
|
||||
M1/M2/M3/M4 are brief/Sage mutants; M5–M10 cover review focus and dewey-0933.
|
||||
Each exited 1 with named assertion failures, no loader/syntax kill.
|
||||
|
||||
| Mutant | Change | Pass/fail | Named killer |
|
||||
| --- | --- | --- | --- |
|
||||
| M1 | skip raise scope check | 11/3 | bus out-of-scope Gitea waits; empty/bad scope waits; host argv exclusion |
|
||||
| M2 | skip invalid-value fallback | 5/2 | business missing/bad scope fallback; bad file scope fallback |
|
||||
| M3 | remove #human from scope verb | 13/1 | bus Q3 caller refusals |
|
||||
| M4 | drop grant.scope.used write | 9/5 | bus Q4 protected evidence assertion; four executor waits also fail |
|
||||
| M5 | skip scope digest check | 13/1 | bus Q4 bad evidence: decision-mismatch instead of not-authorized |
|
||||
| M6 | rethrow narrowing, rolling spend back | 13/1 | bus Q5 Gitea spent assertion |
|
||||
| M7 | same without length check | 6/1 | business Q2 shorter command prefix exclusion |
|
||||
| M8 | no-op #scopeRecheck | 12/2 | bus Q5 Gitea and host scope-changed outcomes |
|
||||
| M9 | remove launch.by check | 13/1 | bus Q6 second chief stays open |
|
||||
| M10 | accept any matched scope evidence row | 13/1 | bus Q4 bad evidence, with positive control |
|
||||
|
||||
Mutation log sha256 (under scratch/review-mutants):
|
||||
|
||||
```text
|
||||
55e9973af5fca0088963e2ca11fd81b2fb46dfead813a577c0e0aad2d6269b68 M1.log
|
||||
abd1b784eff5ae1333a7b47085d3e199cad6d27a00e2584edf0b62417ad4c888 M2.log
|
||||
1a56085e548d1e244f0f88fe12a45f77c162375880794f9ef88b241ed3a1704d M3.log
|
||||
9c120f3f7128488edaf8bfd6d41bae712e69e76da4195a0d4dc0e8336b02038f M4.log
|
||||
1bba88a3752823454acdaa51abde6aad66fdd5488801b4aceda02a3b42c8123d M5.log
|
||||
a4ce3cacca9f7ea56b8cdfd503c76173a8e4ff1d8a7ec07032c93f0b51aa60d8 M6.log
|
||||
acecd5afc93d68d96d713f8a9a2dc08351831a0b7360a13b4e30a572ad4167bf M7.log
|
||||
fce7fa505ccf4f2d4190bd03c209fd19f1cf2770d3ae1246a3bd03697594019e M8.log
|
||||
a830a7964890dd04a2dcc7a932a1502f1dea06419a1eae6b91a85f7878dc5681 M9.log
|
||||
38ce3e7f69f6c6ffe10133397465d52538a930a830f76256a8b9280f8df78e4b M10.log
|
||||
```
|
||||
|
||||
Full touched-package suites and every scripts/test-*.sh remain for Sage's full gate; this CLEAR authorized only the five whole files. Candidate remains
|
||||
marked Untested for the full gate. No queue row has been changed by Boyd.
|
||||
|
||||
## Dewey final delta review (dewey-0935, 2026-10-23 19:31:55Z)
|
||||
|
||||
Dewey reviewed 574ece13c..bcd93dc08 with local git only, no Node: CLEAN,
|
||||
nothing blocks. Verified all 13 manifest entries, exact package-file coverage,
|
||||
project-runtime 1/1, M5-M10 mutation sites, all ten mutation log hashes and
|
||||
counts, named assertion kills without loader/syntax errors, final five logs
|
||||
29/0/0, and N1-N5 coverage. No production code delta in this review range.
|
||||
|
||||
Formal Gitea APPROVE on #1804 and queue record wait on the row 291 hold release
|
||||
and Sage's go. Sage's touched-package and every scripts/test-*.sh gate remains
|
||||
required. Boyd has released lane B and has no running test processes.
|
||||
@@ -1,13 +0,0 @@
|
||||
612488b705853e75e6f8952e41552526f4d07dd297d7619d608d434ed75121d6 packages/bus/README.md
|
||||
3677f403104c121d53eb50b874de7a5d8d22e038faac52a5ea5bad449d0d409c packages/bus/src/broker.mjs
|
||||
2e0ade9233404439cd4a629245600878da960f1126e7299aa2ccfb3aff86d784 packages/bus/src/runtime.mjs
|
||||
e7065db949cacfd4679a1031eebf5cde09ed196ce85612e8400ae563f02a0f18 packages/bus/tests/grant-scope-runtime.test.mjs
|
||||
4030cf584481a1db12d9e6f744015c48fdebd468335d065ead3e90180858d366 packages/bus/tests/grant-scope.test.mjs
|
||||
bc432d8d430433b9d84c00883584dc53d4b27afb5e93f739fe3ca28e80f01efa packages/bus/tests/project-runtime.test.mjs
|
||||
84d6dd15d5ca2926d8f7a34548c70c3e7a3c58e692f0b009414f1af35cb096cc packages/business/src/business.mjs
|
||||
1ac106522c8a115cb9479f2a6c5591fb9c77d138bbe93d2df10b6c4295645d0e packages/business/src/instance-settings.mjs
|
||||
fed4177325f65a2b9823a40a9c35c01d60c0520e3ff40b4574c3cf2c29cc096b packages/business/src/vocabulary.mjs
|
||||
b7f96e45b9ffca2a7ebc18dc446f0adcaac72d3900f098c60157cf6cbf1bfc13 packages/business/tests/grant-scope.test.mjs
|
||||
60fc267b7f8f9e83eeb0d0207c8abdef68d554f32b08d508e98dbf59b442a366 packages/cli/README.md
|
||||
b586907ca8f351f60dc19ec2683df75d2d70ddb12955d09fb14560c9d3bed3dc packages/cli/src/grants.mjs
|
||||
fb7c30b31facd887b03ff02dd6734d48a815978481b33bb21ec48604a51c5ff5 packages/cli/tests/grants-scope.test.mjs
|
||||
@@ -1,105 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Row 298 mutants. Run only with Sage's explicit CLEAR in Boyd's slot.
|
||||
|
||||
Each mutation is applied to this worktree, runs one whole new test file,
|
||||
then restores the exact original bytes, including after an interruption.
|
||||
Logs go to a caller-supplied scratch directory, never the repository root.
|
||||
"""
|
||||
import argparse
|
||||
import datetime
|
||||
import pathlib
|
||||
import subprocess
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[4]
|
||||
BROKER = ROOT / 'packages/bus/src/broker.mjs'
|
||||
VOCABULARY = ROOT / 'packages/business/src/vocabulary.mjs'
|
||||
BUS_TEST = 'packages/bus/tests/grant-scope.test.mjs'
|
||||
BUSINESS_TEST = 'packages/business/tests/grant-scope.test.mjs'
|
||||
|
||||
|
||||
def replace_once(source, old, new):
|
||||
if source.count(old) != 1:
|
||||
raise RuntimeError(f'expected exactly one mutant site, found {source.count(old)}')
|
||||
return source.replace(old, new, 1)
|
||||
|
||||
|
||||
def mutate(name, source):
|
||||
if name == 'M1':
|
||||
return replace_once(source,
|
||||
'if (!raise?.grant || !grantInScope(scope, raise.grant)) return view;',
|
||||
'if (!raise?.grant) return view;')
|
||||
if name == 'M2':
|
||||
return replace_once(source,
|
||||
'return scope === null ? { scope: GRANT_SCOPE_EMPTY, fallback: true } : { scope, fallback: false };',
|
||||
'return { scope: scope ?? value, fallback: false };')
|
||||
if name == 'M3':
|
||||
return replace_once(source,
|
||||
' #grantScopeVerb(s, verb, a) {\n this.#human(s);',
|
||||
' #grantScopeVerb(s, verb, a) {')
|
||||
if name == 'M4':
|
||||
old = """ this.#grantEvent(s.business, {
|
||||
action: 'grant.scope.used', grantDecision: d.id, decisionSeq: seq, method: raise.grant.method,
|
||||
agent: raise.grant.agent, scope, revision, scopeSha256: sha256(JSON.stringify(scope)),
|
||||
}, d.id);"""
|
||||
return replace_once(source, old, ' // MUTANT: scope evidence omitted.')
|
||||
if name == 'M5':
|
||||
return replace_once(source,
|
||||
"evidence.agent === grant.agent && evidence.scopeSha256 === sha256(JSON.stringify(checked.scope)) &&",
|
||||
"evidence.agent === grant.agent &&")
|
||||
if name == 'M6':
|
||||
start = source.index(" checked = this.#store.transaction(() => {\n const consumed = this.#consumeGrant")
|
||||
end = source.index('\n } catch (e) {', start)
|
||||
return source[:start] + " checked = this.#store.transaction(() => this.#giteaRecheck(business, decision, this.#consumeGrant(business, decision, 'gitea')));" + source[end:]
|
||||
if name == 'M7':
|
||||
return replace_once(source,
|
||||
'const same = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]);',
|
||||
'const same = (a, b) => a.every((x, i) => x === b[i]);')
|
||||
if name == 'M8':
|
||||
return replace_once(source,
|
||||
" if (this.#closed(decision)?.via === 'scope' && !grantInScope(this.#scopeNow(business).scope, g)) fail('scope-changed');",
|
||||
" // MUTANT: no spend-time scope recheck.")
|
||||
if name == 'M9':
|
||||
return replace_once(source,
|
||||
' if (s.role !== this.#business(s.business).launch?.by) return view;',
|
||||
' // MUTANT: any chief instance can use scope.')
|
||||
if name == 'M10':
|
||||
start = source.index(' const evidence = JSON.parse(row.body);', source.index(' #scopeUsed('))
|
||||
end = source.index('\n }', start)
|
||||
return source[:start] + ' return true;' + source[end:]
|
||||
raise ValueError(name)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--sage-clear', required=True, help='CLEAR message key; required before any Node run')
|
||||
parser.add_argument('--logs', type=pathlib.Path, required=True)
|
||||
parser.add_argument('--mutant', choices=['M1', 'M2', 'M3', 'M4', 'M5', 'M6', 'M7', 'M8', 'M9', 'M10', 'all'], default='all')
|
||||
parser.add_argument('--deadline', default='2026-10-23T19:38:00+00:00')
|
||||
args = parser.parse_args()
|
||||
deadline = datetime.datetime.fromisoformat(args.deadline)
|
||||
args.logs.mkdir(parents=True, exist_ok=True)
|
||||
names = ['M1', 'M2', 'M3', 'M4', 'M5', 'M6', 'M7', 'M8', 'M9', 'M10'] if args.mutant == 'all' else [args.mutant]
|
||||
for name in names:
|
||||
remaining = (deadline - datetime.datetime.now(datetime.timezone.utc)).total_seconds()
|
||||
if remaining <= 15:
|
||||
raise RuntimeError('Sage slot deadline reached; no new test run')
|
||||
path, test = (VOCABULARY, BUSINESS_TEST) if name in ['M2', 'M7'] else (BROKER, BUS_TEST)
|
||||
original = path.read_bytes()
|
||||
mutated = mutate(name, original.decode())
|
||||
log = args.logs / f'{name}.log'
|
||||
try:
|
||||
path.write_text(mutated)
|
||||
with log.open('w') as output:
|
||||
result = subprocess.run(['timeout', '-k', '10', str(int(min(180, remaining - 13))), 'node', '--test', '--test-concurrency=1', test],
|
||||
cwd=ROOT, stdout=output, stderr=subprocess.STDOUT,
|
||||
timeout=min(195, remaining - 1), check=False)
|
||||
print(f'{name}: exit={result.returncode} log={log}', flush=True)
|
||||
finally:
|
||||
path.write_bytes(original)
|
||||
# Exit 1 needs human inspection of the failing test names; errors are not kills.
|
||||
if result.returncode == 0:
|
||||
raise RuntimeError(f'{name} survived')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,92 +0,0 @@
|
||||
# Row 298 (#1804, Q228): the chief-of-staff grant scope
|
||||
|
||||
Owner Megavolt, reviewer Dewey. Assigned in sage-3584 (mos-1489), 2026-10-23 18:53Z.
|
||||
Base: origin/refactor 45dca69f6. Branch: megavolt/row298.
|
||||
Brief: docs/plans/2026-10-23_mvp-readiness-rows.md, "Brief: the chief-of-staff grant scope (Q228)".
|
||||
Decision: DECISIONS Q228 (jason-master-restart DECISIONS.md:2278). It replaces Mos's Q190 reading.
|
||||
|
||||
## What exists at 45dca69f6 (broker.mjs)
|
||||
|
||||
- Raise: `#raiseGrant` (:2558) is chief-only (`grant-chief-only`) and binds a grant body per method:
|
||||
gitea `#giteaGrantBinding` (:2665), host `#hostGrantBinding` (:2599), or host-enroll `enrollBinding`.
|
||||
The decision goes through `#dispatch(..., 'decision.raise', ...)`, routed to the human.
|
||||
- Approval: `#approvedGrant` (:1525) returns the binding only for a credential.mint routed to the
|
||||
human and closed with `approvalChoice` by the business's human, via 'cli', or via 'console' with
|
||||
the broker's own human.input (row 252).
|
||||
- Start: decision.resolve (:5653-5659) runs `#approvedGrant` after the close and, once the transaction
|
||||
commits, starts `#runEnrollment`, `#runHostGrant` or `#startGitea` with setImmediate.
|
||||
- Spend and records: each executor calls `#consumeGrant` and writes the 236 records (grant.call,
|
||||
grant.result, grant.step, grant.applied or grant.failed, gitea_grants or host_grants). Revoke
|
||||
goes through grants.revoke and hosts.revoke, as before.
|
||||
- Human-only settings: `#human(s)` (cli, outsideAgent) plus `#mayHuman`, as in veto.set (:4713).
|
||||
The organization file is checked whole by business.mjs `checkLaunch`, and a bad file refuses
|
||||
every read.
|
||||
|
||||
## Proposed design (waits on Sage's rulings, Q1-Q6 below)
|
||||
|
||||
D1. Storage. The scope gets its own key, `launch.grantScope`, in the organization file. The
|
||||
whole-file check doesn't refuse on it. A separate reader, `grantScope(document)`, returns the
|
||||
parsed scope or, when the key is missing or bad, the empty scope `{}`, plus a `fallback` flag.
|
||||
A bad value can't break every organization read, and it falls back to "everything needs
|
||||
Approve", which is the fail-closed direction. (Q1)
|
||||
D2. Shape. `{ gitea?: { agents: [id], projects: [id] }, host?: { agents: [id], hosts: [id],
|
||||
commands: [[argv prefix]] } }`. A request is in scope only if its method has an entry, its agent
|
||||
is listed, and every target is listed: each gitea project, the host, and each command prefix
|
||||
equal to a listed prefix. host-enroll is never in scope, because it adds a host to the registry.
|
||||
(Q2)
|
||||
D3. Writer. A new verb, `grants.scope.set { revision, scope }`, CLI-only for the human: `#human(s)`,
|
||||
then `#mayHuman`, with the verb added to LIFECYCLE_HUMAN_VERBS. The tester, the reader and every
|
||||
agent are refused. organization.set's ORGANIZATION_KEYS don't include it, so the chief's tool
|
||||
never reaches it, the same way instances.set and veto.set stay human-only. The write validates
|
||||
strictly and refuses a bad value; only the read falls back. Receipt: `grants.scope` with the old
|
||||
and new sha256. `grants.scope` reads the value for the human, the tester and the chief. (Q3)
|
||||
D4. In-scope raise. Inside the raise transaction, `#raiseGrant` checks the binding against the scope
|
||||
and, if it's in scope, closes the new decision at once: op resolved, choice approvalChoice, by
|
||||
`grant-scope`, via `scope`. It writes a `grant.scope.used` event carrying the scope sha256 and
|
||||
revision. `#approvedGrant` accepts via 'scope' only when that event exists for the close's seq
|
||||
and its sha256 matches the scope as read in the same transaction. After the commit, the same
|
||||
setImmediate starts the same executor, so 236's spend, records and revoke are unchanged. The
|
||||
decision is closed before any inbox read, so no inbox item ever shows. (Q4)
|
||||
D5. Out-of-scope raise: unchanged. The decision stays open, routed to the human, for the Console
|
||||
and CLI Approve.
|
||||
D6. Executor recheck. At the spend, `#consumeGrant` reruns the scope check for a via-'scope'
|
||||
approval. If the scope changed between raise and run and the grant is now out of scope, it
|
||||
fails with `grant.failed scope-changed` and the grant is spent. (Q5)
|
||||
|
||||
## Open questions for Sage
|
||||
|
||||
Q1. Storage: `launch.grantScope` in the organization file with a lenient reader (D1), or a separate
|
||||
file under the business directory?
|
||||
Q2. Shape: is per-method agent, project, host and command lists right? Should host-enroll stay
|
||||
out of scope always?
|
||||
Q3. "A scope change needs Approve": is the human's CLI verb itself the Approve (D3)? Or must the
|
||||
chief also be able to raise a scope change that Jason approves in the inbox (a new decision
|
||||
action, more code)? I recommend D3 alone. Should Console get a scope page in this row? I
|
||||
propose no.
|
||||
Q4. Recording an in-scope grant as a broker close via 'scope' with a scope event (D4), versus a
|
||||
decision that is never raised (a new table). D4 keeps 236's records and revoke unchanged.
|
||||
Q5. Scope narrowed between raise and run: fail scope-changed (D6), or honor the raise-time check?
|
||||
Q6. Chief enrollment ("Jason's Approve stays for the chief of staff's enrollment"): I read this
|
||||
as the chief's own instance and credentials, which the human's instances.set and the
|
||||
credential lifecycle already cover. So nothing new is needed in this row. Agree?
|
||||
|
||||
## Tests (access by brief; all on scratch stores, fake Gitea, fake host)
|
||||
|
||||
T1 agent, chief and tester callers of grants.scope.set are refused, and organization.set with
|
||||
grantScope is refused. T2 a bad or missing grantScope falls back to empty and the grant waits.
|
||||
T3 an in-scope gitea request issues with no open decision and no inbox item, and writes the 236
|
||||
records. T4 the same for a host request. T5 out of scope by agent, by project, or by command
|
||||
waits for Approve. T6 host-enroll always waits. T7 a scope change by the human's CLI writes a
|
||||
receipt; a scope change by any other path is refused. T8 an in-scope grant is logged and revoked
|
||||
with grants.revoke or hosts.revoke. T9 scope narrowed before run gives scope-changed.
|
||||
|
||||
## Mutants (brief)
|
||||
|
||||
M1 the scope check skipped (every chief raise auto-closes): killed by T5 and T6.
|
||||
M2 the fallback skipped (a bad value is used as-is or throws): killed by T2.
|
||||
M3 the `#human` check removed from grants.scope.set: killed by T1.
|
||||
|
||||
## Holds
|
||||
|
||||
Darkwing holds the slot: no push, no claim, no queue write, no node and no test run until Sage
|
||||
says so. Q188: no agent creates a grant; tests use scratch stores only.
|
||||
@@ -0,0 +1,177 @@
|
||||
# Row 225 (C2-10b) Console chat: replies, citations, write notices, status line
|
||||
|
||||
Owner: Morgana, from sage-3582 (18:53Z 10-23). Base: origin/refactor 45dca69f6. Branch: morgana/row225.
|
||||
Brief: docs/plans/2026-10-20_case2-rows.md, C2-10b. Design: agents/gosalyn/work/case2/DESIGN.md 7.4,
|
||||
5.2 (the reply schema and the check result) and the write notice and `library.revert` (about line 286).
|
||||
|
||||
## Ruling on the dependency gap (sage-3591)
|
||||
|
||||
225 builds against the design contracts. `renderReply` stays pure, and the caller supplies the answers
|
||||
it can't know. The revert route proxies to the bus verb and shows the refusal code until the verb
|
||||
exists. Landing order: 225 lands after 217 (C2-06), not after 288 (C2-02). Sage adds `after 217` to
|
||||
row 225. Before 288 lands, the Revert button gets the bus's refusal and shows its code, as the brief
|
||||
allows.
|
||||
|
||||
## A1. The check result I assume (to 217, Launchpad)
|
||||
|
||||
The broker stores one check result with each agent-to-human message the guard passes. The Console reads
|
||||
it on the message row that `/api/chat/threads/<id>/messages` returns, under the key `check`:
|
||||
|
||||
```json
|
||||
{
|
||||
"check": {
|
||||
"citations": [
|
||||
{ "scope": "canon", "agent": null },
|
||||
{ "scope": "supporting", "agent": "acme/scribe" },
|
||||
{ "scope": "web", "agent": null }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `check` is `null` or absent on a message the guard didn't check: a human's message, a free-text reply,
|
||||
a notice, or a message from before 217.
|
||||
- `check.citations` has one entry per entry of the body's `citations`, in the same order.
|
||||
- `scope` is one of `canon`, `playground`, `supporting` and `web`. A url cite is `web`.
|
||||
- `agent` is the `Mosaic-Agent` trailer value, `<business>/<instance>`, from
|
||||
`git log -1 --format=%(trailers:key=Mosaic-Agent,valueonly) <commit> -- <path>`, or `null` when the
|
||||
file's last commit at `commit` carries none. A url cite has `agent: null`. The Console shows the
|
||||
agent-written label when `agent` is a nonempty string, and names the agent.
|
||||
|
||||
The Console trusts nothing in the body for scope or label. A cite with no matching check entry renders
|
||||
with no badge and no label. It doesn't fall back to a scope it worked out itself.
|
||||
|
||||
If 217 uses other names, I change mine to theirs before either of us pushes (sage-3591 item 1).
|
||||
|
||||
## A2. The revert verb I assume (to 288, Honker)
|
||||
|
||||
- Verb: `library.revert`. A human verb (design M4): from an agent session it refuses `human-required`,
|
||||
like `library.status` (`broker.mjs:5133`).
|
||||
- Args: `{ "project": <business project name>, "commit": <40 lowercase hex> }`. The design writes
|
||||
`library.revert {commit}`. `project` is added to match the other library verbs, and like theirs it is the
|
||||
key of `business.projects` (`mirror.mjs:31`), which is the chat project's `repo` link, not its UUID. Both are required, and no other key
|
||||
is allowed (`invalid-request`), as the other library verbs check with `keys(args, ...)`.
|
||||
- Result: `{ "project", "path", "reverted": <the commit given>, "commit": <the new commit> }`.
|
||||
- Refusal codes the Console shows by name:
|
||||
- `library-revert-not-agent`: `commit` has no `Mosaic-Agent` trailer for this business, or isn't
|
||||
reachable from the served commit.
|
||||
- `stale-base`: a later commit changed that path.
|
||||
- `human-required`, `invalid-request`, `project-not-configured`.
|
||||
- Before 288 lands, the bus refuses the verb (expected `unknown-verb`, `broker.mjs:6578`), and the Console
|
||||
shows that code.
|
||||
- HTTP status in the Console (`packages/webui/src/bus.mjs` `busStatus`): `stale-base` and
|
||||
`library-revert-not-agent` map to 409. Every other code keeps the existing mapping.
|
||||
|
||||
## A3. The status line
|
||||
|
||||
`GET /api/library/status?project=<id>` proxies `library.status {project}` through the existing
|
||||
`libraryStatus` option. `cli.mjs:110` already passes that option, but `startServer` doesn't read it.
|
||||
The status line shows:
|
||||
|
||||
- the served commit (7 characters) and its age;
|
||||
- `library-diverged` when the sync state names it;
|
||||
- the fetch error code;
|
||||
- the reply limits in force, and the keys that fell back;
|
||||
- the count of R-problems, linking to the list.
|
||||
|
||||
It reads the fields `readSyncState` returns at the base (`packages/library/src/mirror.mjs:232`). It shows
|
||||
any field 217 adds (the limits) only when present.
|
||||
|
||||
## A4. "Changed since" (ruled sage-3596)
|
||||
|
||||
Sage ruled that 217 supplies `last`. On a `thread.messages` row that carries `check`, each path cite's
|
||||
entry gets `last`, the file's last commit at the served tip, read when the message is read. When `last`
|
||||
is absent the Console marks nothing changed, and a test pins that.
|
||||
|
||||
One more field is needed, raised in morgana-0200. The cited `commit` is a library commit, not the
|
||||
file's own last commit, so a cite made at a commit that didn't touch the file would differ from `last`
|
||||
while the file is unchanged. I asked 217 to also store `file`, the file's last commit at the cited
|
||||
commit. That's `%H` from the same `git log -1 <commit> -- <path>` call that reads the trailer, so it
|
||||
costs no extra git call. The Console marks a cite changed when both `file` and `last` are 40-hex and
|
||||
differ. The cited commit is reachable from the served one (5.2), so `file` is an ancestor of `last`,
|
||||
and differing means newer. A missing or malformed `file` or `last` marks nothing.
|
||||
|
||||
The entry the Console reads, in full: `{ scope, agent, file?, last? }`.
|
||||
|
||||
## A5. `no-cited-answer` retires
|
||||
|
||||
`renderItems` and its `NOTICES` (`no-cited-answer`, `no-answer`, `turn-failed`) go. 217 sends the
|
||||
runner's fixed texts as plain bodies (design 5.2, "Runner texts"), and renderReply shows those as free
|
||||
text. Design 7.4 names no other notice than `library-write`, so nothing is lost (sage-3596 item 3).
|
||||
|
||||
## Files
|
||||
|
||||
Both deviations below were accepted in sage-3596 and are named in the manifest for Dewey. The brief owns these:
|
||||
|
||||
- `packages/library/src/render.mjs`: `renderReply` replaces `renderItems`.
|
||||
- `packages/library/src/index.mjs`: the export.
|
||||
- `packages/library/README.md`: the section.
|
||||
- `packages/library/tests/render.test.mjs`.
|
||||
|
||||
It also owns `bus.js`, its stylesheet, `serve.mjs` (only the two routes) and new webui tests. Two
|
||||
deviations, both recorded here:
|
||||
|
||||
- The chat view's message list is `packages/webui/src/public/chat.js:85` (`window.MosaicChat`), not
|
||||
`bus.js`. The reply, notice and Revert rendering goes in `chat.js` and `chat.css`. The status line
|
||||
goes in the chat thread view, so it is also in `chat.js`.
|
||||
- `packages/webui/src/console-bus.mjs` and `cli.mjs` each get one `libraryRevert` line, beside the
|
||||
existing `libraryStatus` wiring.
|
||||
|
||||
## Overlap with row 220 (C2-10a, Researcher, 3ef6da90d)
|
||||
|
||||
220 changes one line each in the webui tests discord.test.mjs, fixture.mjs, history-fixture.mjs,
|
||||
relaunch.test.mjs and return-flow.test.mjs. Its other paths are in control-board, conversation, discord
|
||||
and business. 225 doesn't touch those five files or those packages. Its webui tests are new files.
|
||||
|
||||
## Handoff to Fethry (mos-1501, Q245)
|
||||
|
||||
The row moved to Fethry (Codex, fethry/row225). Everything below is **untested**: no suite or single file
|
||||
has run on it, since a run needs Sage's CLEAR. Each changed file passes `node --check`.
|
||||
|
||||
Done in the handoff commit:
|
||||
|
||||
- `render.mjs` `renderReply(body, {check, changed, name})`; index export; README section;
|
||||
`render.test.mjs` rewritten. `renderItems` is gone from every package.
|
||||
- `serve.mjs`: `/render.mjs` static entry (owner and user file maps), `GET /api/library/status`
|
||||
(one `project` query, answer must name that project), `POST /api/library/revert` (save's boundary,
|
||||
strict `{project, commit}`, refusal shows the code, lost or unusable answer is `outcome-unknown`,
|
||||
never retried). Both are nulled for a granted user, and `userRoute` keeps them owner-only. The tester
|
||||
listener refuses `libraryRevert`.
|
||||
- `bus.mjs`: `library-revert-not-agent: 409`. That re-pins `packages/webui/src/bus.mjs` in
|
||||
`packages/bus/tests/channel-proof.test.mjs`, with a comment line, as row 254 did. It's a third deviation,
|
||||
in another package's test.
|
||||
- `console-bus.mjs` and `cli.mjs`: `libraryRevert`, beside `libraryStatus`.
|
||||
- `chat.js`: dynamic `import('/render.mjs')` (CSP is `script-src 'self'`); body text until it loads.
|
||||
Cites with a scope badge, the agent-written label, "changed since" from `file`/`last`, links only for
|
||||
https with `rel="noopener noreferrer"`. A notice gets a Revert button behind `confirm()`. The library
|
||||
status line goes under the thread heading.
|
||||
- `chat.css`: cite, badge, notice and status styles.
|
||||
- New tests: `packages/webui/tests/library-routes.test.mjs` and
|
||||
`packages/webui/tests/chat-replies-browser.test.mjs`. 220's five files are untouched.
|
||||
|
||||
Decisions made in the build:
|
||||
|
||||
- The library name is the chat project's `repo`. A project with no repo reads no status and its
|
||||
Revert does nothing.
|
||||
- These codes hide the status line: `project-not-declared`, `library-mapping-invalid`,
|
||||
`project-not-configured`, `not-configured` and `owner-only`.
|
||||
- Otherwise every code-repo thread, and every granted user, would show "unavailable".
|
||||
- Cost: a broken library mapping also hides the line. `library.status` from the CLI still shows it.
|
||||
- "Age" is the time since `lastFetch` ("fetched 5 min ago"), not the served commit's age, which the
|
||||
status doesn't carry.
|
||||
- The limits fields are assumed to be `replyLimits {requireCitation, citationsPerReply}` and
|
||||
`replyLimitsFallback [keys]`, and are shown only when present. **Not agreed with 217.** Confirm with
|
||||
Beakley and rename.
|
||||
- A notice body's shape is all the renderer checks. An agent in a business without a library could
|
||||
send a body that looks like a notice. The page would show it, but `library.revert` still refuses it.
|
||||
|
||||
Left:
|
||||
|
||||
1. Get CLEAR and run the webui and library suites, `channel-proof.test.mjs`, and the two new files whole.
|
||||
The browser test's selectors and waits have never run.
|
||||
2. Agree the limits field names with 217 (Beakley).
|
||||
3. Granted-user R6 G5 (`granted-user.test.mjs:252`) lists owner verbs by hand. Add `library.revert`
|
||||
once 288 lands.
|
||||
4. BUILD.md: the mutant list and the mos-1275 timeout plan.
|
||||
5. The manifest names the three deviations: chat.js and chat.css in place of bus.js, the console-bus and
|
||||
cli lines, and the channel-proof re-pin.
|
||||
@@ -1593,7 +1593,3 @@ are never rewritten or removed; corrections are new entries.
|
||||
2026-10-23T17:48:43Z | Scrooge (629b6cd3) | Row 252 Console inbox Approve and Deny (#1753) | Rebased onto 306 landing as 580037105 (range-diff all =), slot run bus 907/0 webui 410/0 cli 943/0, in-review revs 3608/3609, Webby r1 APPROVE, J1-J19 specs to Sage; Sage landed 45ea3fa0d (done rev 3616). Part 1 mutant results were never in BUILD.md; they went to Sage by T3 (scrooge-0252-mutant-specs).
|
||||
2026-10-23 | Sage (1ef1e4f8) | Rows 308 and 252 landings, 571 Connections design amendment, Vikunja 597 | 308 (PRD 1.17, #1815) landed 30e6bb680 and 252 (#1753) landed 45ea3fa0d (replay of 580037105 onto 30e6bb680, range-diff all =), each after a full gate on the landed tree (252 also J1-J19, J11c and J18 equivalent); Gyro's 571 amendment (Fenton APPROVE) landed e8bb13df8; Vikunja 570, 435 and 597 done, canonical webui esbuild 0.28.2; queue revs 3589-3616 committed 59f013247
|
||||
2026-10-23 | Sage (1ef1e4f8) | Vikunja 571 W build rows; rows 13, 42, 257 and 289 check | Filed the design's ten W rows as queue rows 309-318 (revs 3617-3626, owners set to unassigned 3627-3636, queue commit 293b42fee pushed); Vikunja 571 done (comment 994). Row 13's candidate fd72d2689 is on refactor, gate is Monday's row 7 ledger run; 42 paused under mos-1184; 257 post-v1; 289 waits on 214 and 288. Slot released 17:56Z.
|
||||
|
||||
2026-10-23T19:13:00Z | Boyd (48a3ae38, Codex gpt-6.1-sol) | Row 298 (#1804, Q228) takeover from Megavolt under mos-1496 | Started on boyd/row298 at 45dca69f6; reading brief, D1-D6 and human-shell proof; awaiting Megavolt handoff. No Node/tests before Sage CLEAR, no queue writes.
|
||||
|
||||
2026-10-23T19:29:00Z | Boyd (48a3ae38) | Row 298 (#1804) targeted T1 lane B | Five whole files 29/0/0, ten mutants killed, source restored; reviewer delta prepared. Earlier startup timestamp corrected in Boyd BUILD.md. Full gate and queue/landing remain Sage's.
|
||||
@@ -226,8 +226,6 @@ an operator inspect content and delivery evidence after an uncertain result.
|
||||
| `hosts.remove` | `{host}`; the human or the tester. Deletes the record, writes `grant.host.removed` and removes the installer key. A `host_grants` record for the host is `host-in-use`, a guarded event naming a host line left on the host `host-line-left`. |
|
||||
| `hosts.revoke` | `{agent,host,reason}`; the human, the business's assistant, or the tester when `bus.tester` is exactly `true` (row 235, #1741, r6 section 6). Anyone else gets `revoke-refused`. The tester check reads `bus.tester` again at the call, `tester-dev-only`. One transaction deletes the `host_grants` record and writes `grant.revoked` with `hostLine:"pending"`. With no await between, the key leaves memory and its file goes, and a `host.run` or executor in flight aborts. Then `remove <user> <tag>` runs on the host and `grant.revoke.closed` records `hostLine:"removed"` or `hostLineLeft`. A revoke with no record still runs, with `granted:false`. Returns `{agent,host,tag,granted,keyFile,hostLine or hostLineLeft}`. A restart with a pending marker runs the remove once the bus is up; `hosts.status` lists its tag under `revoking`. |
|
||||
| `hosts.sweep` | `{host}`; the human or the tester (r6 X3). Runs `list <user>` and removes each listed tag this business recorded for the host in a `grant.step` or `grant.revoked` that no live grant holds. Another business's line and an unrecorded `mosaic-` line stay. Writes `grant.host.swept {host,removed,left}`; `hostLineLeft` then counts from it. Returns `{host,removed,hostLineLeft}`. |
|
||||
| `grants.scope` | `{}`; the proven human CLI only (row 298, #1804, Q228). Returns the organization revision, effective scope and fallback flag. `launch.grantScope` missing or invalid covers nothing. |
|
||||
| `grants.scope.set` | `{revision,scope}`; the proven human CLI only, itself Approve for the scope change. Agents, testers, readers and Console caps refuse. Strict scope shape; optimistic organization revision, durable organization receipt, and `grant.scope` with human proof and old/new scope digests. General organization setters cannot write this setting. Scope matches per-method agent and every project, or agent/host and exact command arrays; host enrollment is excluded. In-scope requests by `launch.by` close via `scope` with protected `grant.scope.used` evidence, and run the existing grant executor without per-grant Approve. The raise returns the closed decision view to the chief; this shortcut sends no separate decision-close message. Outside scope waits as before. Scope narrowing before spend fails `scope-changed`; every issued grant keeps its records and revoke path. Empty default ships; the live value is Jason's to set. |
|
||||
| `grants.status` | `{service:"gitea"}`; the human, the tester or the chief (row 236, #1742, #436b r5). The admin slot's url, account and rotate-by date with `rotationDue`, `adminTokenLeft`, the `gitea_users` and `gitea_grants` records, approved decisions not yet run (`spentAtNextStart`), grants `running` or `settlePending`, agents held as `tokenUnknown`, the `serviceLeft` entries a sweep retries, and revokes still `revoking`. No token. |
|
||||
| `grants.run` | `{decision}`; the human only. Starts the Gitea executor of a `credential.mint` decision with a gitea grant the human said yes to, as the yes itself does (X1). Step 0's refusals come back with no record: `not-authorized`, `decision-consumed`, `grant-token-unknown`, `grant-busy`, `gitea-not-configured`. Returns `{decision,agent,started:true}`; the outcome is a `grant.applied` or `grant.failed` and a notice. |
|
||||
| `grants.revoke` | `{service:"gitea",agent,reason}`; the human, the business's assistant, or the tester when `bus.tester` is exactly `true`. The token leaves memory and its file goes, an executor in flight aborts, then one transaction deletes the `gitea_grants` record and writes `grant.revoked` with the token ids to delete. The deletes run with the admin token after C1 to C4, and `grant.revoke.closed` records `serviceLeft`, a list. `credentials.revoke AGENT gitea` and `credentials.delete` do the same for a stack-made user; delete also deletes every token of the user and sets `prohibit_login` (fire). |
|
||||
|
||||
+7
-126
@@ -4,7 +4,7 @@ import { initializeControl, processingStatus } from '../../processing-control/sr
|
||||
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
|
||||
import { loadRequirements } from '../../tasks/src/requirements.mjs';
|
||||
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
|
||||
import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../business/src/vocabulary.mjs';
|
||||
import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';
|
||||
|
||||
// Kept closed while S1 lands; its vocabulary is the integration authority.
|
||||
export const ACTIONS = Object.freeze([
|
||||
@@ -290,9 +290,6 @@ export function writeGrantEvent(store, at, business, body, subject = null) {
|
||||
);
|
||||
return eid;
|
||||
}
|
||||
// Row 298 (#1804, Q228): the by of a close the chief's grant scope makes, with via 'scope'. No session
|
||||
// closes as it, and #approvedGrant takes it only with the grant.scope.used record of that close.
|
||||
const SCOPE_BY = 'grant-scope';
|
||||
export const HOSTS_VERBS = Object.freeze(['hosts.status', 'hosts.check', 'hosts.remove', 'hosts.revoke', 'hosts.sweep']);
|
||||
// Row 236 (#1742, #436b r5): the Gitea grant path's verbs. run and admin.* are the human's alone.
|
||||
export const GRANTS_VERBS = Object.freeze([
|
||||
@@ -415,7 +412,6 @@ export class Broker {
|
||||
#revokeUndo = [];
|
||||
#testerOn;
|
||||
#roleFile;
|
||||
#grantScope;
|
||||
// Row 236 (#436b): giteaGrant and giteaKeys from gitea-grant.mjs, or null, and the remote halves of
|
||||
// section 5's restart rows and of revokes a restart cut short, for reconcileGitea.
|
||||
#giteaGrant;
|
||||
@@ -443,12 +439,11 @@ export class Broker {
|
||||
#clock = 0;
|
||||
#processing;
|
||||
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
|
||||
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null }) {
|
||||
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null }) {
|
||||
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
|
||||
// Row 194 b15 (sage-2501): the function-argument checks come before anything reads the store.
|
||||
if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function');
|
||||
if (testerOn !== null && typeof testerOn !== 'function') throw new TypeError('testerOn must be a function');
|
||||
if (grantScope !== null && typeof grantScope !== 'function') throw new TypeError('grantScope must be a function');
|
||||
if (roleFile !== null && typeof roleFile !== 'function') throw new TypeError('roleFile must be a function');
|
||||
if (access !== null && typeof access !== 'function') throw new TypeError('access must be a function');
|
||||
if (accessStamp !== null && typeof accessStamp !== 'function') throw new TypeError('accessStamp must be a function');
|
||||
@@ -486,9 +481,6 @@ export class Broker {
|
||||
// sets bus.tester to exactly true; the bus runtime reads it live, so a hand edit counts at the next
|
||||
// tester lifecycle call and at the take. Without it the broker's own copy decides.
|
||||
this.#testerOn = testerOn ?? ((business) => this.#business(business).vars?.['bus.tester'] === true);
|
||||
// Row 298 (#1804, Q228): grantScope(business) reads {value, revision} atomically from the
|
||||
// organization file. Without a configured reader no scope can authorize a grant.
|
||||
this.#grantScope = grantScope ?? (() => ({ value: undefined, revision: null }));
|
||||
// Row 194 (#1692, D2.3): roleFile(name) loads role definition `name` from the roles directory as it
|
||||
// stands, for the hire checks. Without it a hire refuses not-configured.
|
||||
this.#roleFile = roleFile;
|
||||
@@ -1535,78 +1527,12 @@ 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) return null;
|
||||
// Row 298 (#1804, Q228, sage-3594 Q4): a scope close counts only with its own grant.scope.used,
|
||||
// which only #scopeClose writes, under the grant actor.
|
||||
if (r.via === 'scope') return r.by === SCOPE_BY && this.#scopeUsed(business, decision, r.seq, raise.grant) ? raise.grant : null;
|
||||
if (r.by !== this.#business(business).human) return null;
|
||||
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;
|
||||
return raise.grant;
|
||||
}
|
||||
// Row 298: the grant.scope.used record of scope close seq. Gitea and host grants only.
|
||||
#scopeUsed(business, decision, seq, grant) {
|
||||
if (grant.method !== 'gitea' && grant.method !== 'host') return false;
|
||||
const row = this.#store.get(
|
||||
`SELECT body FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.action')='grant.scope.used'
|
||||
AND json_extract(body,'$.grantDecision')=? AND json_extract(body,'$.decisionSeq')=? AND json_extract(body,'$.method')=? LIMIT 1`,
|
||||
business, decision, seq, grant.method,
|
||||
);
|
||||
if (!row) return false;
|
||||
const evidence = JSON.parse(row.body);
|
||||
const checked = grantScopeOf(evidence.scope);
|
||||
// Verify the immutable raise-time snapshot, not today's scope: scope changes do not
|
||||
// invalidate already issued grants. The executor rechecks today's scope before spend.
|
||||
return !checked.fallback && typeof evidence.revision === 'string' && /^[a-f0-9]{64}$/.test(evidence.revision) &&
|
||||
evidence.agent === grant.agent && evidence.scopeSha256 === sha256(JSON.stringify(checked.scope)) &&
|
||||
grantInScope(checked.scope, grant);
|
||||
}
|
||||
// Row 298: the live organization snapshot. Missing/bad scope covers nothing. An unreadable
|
||||
// organization file or missing revision cannot authorize a scope close either.
|
||||
#scopeNow(business) {
|
||||
try {
|
||||
const snapshot = this.#grantScope(business);
|
||||
return { ...grantScopeOf(snapshot?.value), revision: snapshot?.revision ?? null };
|
||||
} catch {
|
||||
return { ...grantScopeOf(undefined), revision: null };
|
||||
}
|
||||
}
|
||||
|
||||
// Row 298 (D4, sage-3594 Q2/Q4): after a gitea or host raise, in its transaction. When the raiser is
|
||||
// launch.by, the decision went to the human and the grant is inside the scope, the broker closes it
|
||||
// with the approval choice, via 'scope', writes grant.scope.used and starts the executor, as the
|
||||
// human's Approve does. The spend, the records and the revoke are row 236's and 235's, unchanged.
|
||||
// Anything else is left in the inbox. launch.by is a hand edit, so a chief-of-staff instance the
|
||||
// tester or an instances.set adds never raises in scope (Q6).
|
||||
#scopeClose(s, view) {
|
||||
if (s.role !== this.#business(s.business).launch?.by) return view;
|
||||
const d = this.#store.get('SELECT id,route_to FROM decisions WHERE business=? AND id=?', s.business, view.id);
|
||||
if (d?.route_to !== 'human' || this.#closed(d.id)) return view;
|
||||
const raise = this.#raised(s.business, d.id);
|
||||
const { scope, revision } = this.#scopeNow(s.business);
|
||||
if (typeof revision !== 'string' || !/^[a-f0-9]{64}$/.test(revision)) return view;
|
||||
if (!raise?.grant || !grantInScope(scope, raise.grant)) return view;
|
||||
const seq = Number(
|
||||
this.#store.run(
|
||||
'INSERT INTO decision_events(decision,at,op,by,choice,note,via) VALUES(?,?,?,?,?,?,?)',
|
||||
d.id, this.#now(), 'resolved', SCOPE_BY, raise.approvalChoice, null, 'scope',
|
||||
).lastInsertRowid,
|
||||
);
|
||||
this.#grantEvent(s.business, {
|
||||
action: 'grant.scope.used', grantDecision: d.id, decisionSeq: seq, method: raise.grant.method,
|
||||
agent: raise.grant.agent, scope, revision, scopeSha256: sha256(JSON.stringify(scope)),
|
||||
}, d.id);
|
||||
// As the resolve's: each executor rechecks all of this itself, so a rollback starts nothing.
|
||||
if (raise.grant.method === 'host') setImmediate(() => this.#runHostGrant(s.business, d.id));
|
||||
if (raise.grant.method === 'gitea') setImmediate(() => this.#startGitea(s.business, d.id));
|
||||
return this.#decisionView(this.#decision(s, d.id));
|
||||
}
|
||||
// Row 298 (D6, sage-3594 Q5): at the spend, a scope close needs its grant still inside the scope.
|
||||
// A narrowed scope ends it scope-changed, and the human's Approve is the way back.
|
||||
#scopeRecheck(business, decision, g) {
|
||||
if (this.#closed(decision)?.via === 'scope' && !grantInScope(this.#scopeNow(business).scope, g)) fail('scope-changed');
|
||||
}
|
||||
// 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) {
|
||||
@@ -1839,7 +1765,6 @@ export class Broker {
|
||||
}
|
||||
// r6 X2 step 1's checks after the spend, R4's binding again. Throws the code that ends the grant.
|
||||
#hostGrantRecheck(business, decision, g) {
|
||||
this.#scopeRecheck(business, decision, g);
|
||||
if (!this.#hostGrant || !this.#hostKeys) fail('host-not-configured');
|
||||
const definition = this.#hostDefinition(business, g.agent);
|
||||
if (this.#hostGrant.definitionSha256(definition) !== g.definitionSha256) fail('decision-mismatch');
|
||||
@@ -2640,7 +2565,7 @@ export class Broker {
|
||||
if (!this.#giteaGrant) fail('gitea-not-configured');
|
||||
const g = this.#giteaGrant.giteaGrantCheck(a.grant, this.#now().slice(0, 10));
|
||||
return this.#store.transaction(() => {
|
||||
const result = this.#scopeClose(s, this.#dispatch(s, 'decision.raise', a, null, this.#giteaGrantBinding(s.business, g)));
|
||||
const result = this.#dispatch(s, 'decision.raise', a, null, this.#giteaGrantBinding(s.business, g));
|
||||
this.#secretCheck(result);
|
||||
return result;
|
||||
});
|
||||
@@ -2649,7 +2574,7 @@ export class Broker {
|
||||
if (a.grant?.method === 'host') {
|
||||
const g = this.#hostGrant.hostGrantCheck(a.grant, this.#now().slice(0, 10));
|
||||
return this.#store.transaction(() => {
|
||||
const result = this.#scopeClose(s, this.#dispatch(s, 'decision.raise', a, null, this.#hostGrantBinding(s.business, g)));
|
||||
const result = this.#dispatch(s, 'decision.raise', a, null, this.#hostGrantBinding(s.business, g));
|
||||
this.#secretCheck(result);
|
||||
return result;
|
||||
});
|
||||
@@ -2771,7 +2696,6 @@ export class Broker {
|
||||
}
|
||||
// Section 4 step 1's checks after the spend. Throws the code that ends the grant.
|
||||
#giteaRecheck(business, decision, g) {
|
||||
this.#scopeRecheck(business, decision, g);
|
||||
const { b, role, definition } = this.#giteaRole(business, g.agent);
|
||||
if (this.#giteaGrant.definitionSha256(definition) !== g.definitionSha256) fail('decision-mismatch');
|
||||
if (this.#giteaGrant.identitySha256(role, b.projects) !== g.identitySha256) fail('decision-mismatch');
|
||||
@@ -2922,22 +2846,10 @@ export class Broker {
|
||||
} catch {}
|
||||
return code;
|
||||
}
|
||||
// Step 1, one transaction: spend and recheck. scope-changed ends spent (D6);
|
||||
// other refusals retain the existing rollback behavior.
|
||||
// Step 1, one transaction: the spend and the rechecks. A refusal rolls the spend back.
|
||||
let checked;
|
||||
try {
|
||||
checked = this.#store.transaction(() => {
|
||||
const consumed = this.#consumeGrant(business, decision, 'gitea');
|
||||
try {
|
||||
return this.#giteaRecheck(business, decision, consumed);
|
||||
} catch (e) {
|
||||
// D6: scope narrowing is a terminal, spent failure. Other recheck
|
||||
// refusals retain row 236's transaction/rollback behavior.
|
||||
if (!(e instanceof BusError) || e.code !== 'scope-changed') throw e;
|
||||
this.#giteaFailed(business, decision, agent, e.code);
|
||||
return { scopeChanged: true };
|
||||
}
|
||||
});
|
||||
checked = this.#store.transaction(() => this.#giteaRecheck(business, decision, this.#consumeGrant(business, decision, 'gitea')));
|
||||
} catch (e) {
|
||||
const code = e instanceof BusError ? e.code : 'gitea-failed';
|
||||
if (code !== 'not-authorized' && code !== 'decision-consumed') {
|
||||
@@ -2947,7 +2859,6 @@ export class Broker {
|
||||
}
|
||||
return code;
|
||||
}
|
||||
if (checked.scopeChanged) return 'scope-changed';
|
||||
return await this.#giteaSteps(business, decision, g, signal, log, api, meta, checked, stop);
|
||||
} finally {
|
||||
this.#giteaKeys.release(business, agent, signal);
|
||||
@@ -4758,8 +4669,6 @@ export class Broker {
|
||||
if (CREDENTIAL_VERBS.includes(request.verb)) return this.#requestCredential(s, request.verb, structuredClone(args));
|
||||
if (request.verb === 'instance.hire' || request.verb === 'instance.fire')
|
||||
return this.#chiefLifecycle(s, request.verb, structuredClone(args));
|
||||
if (request.verb === 'grants.scope' || request.verb === 'grants.scope.set')
|
||||
return this.#grantScopeVerb(s, request.verb, structuredClone(args));
|
||||
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set"].includes(request.verb)) {
|
||||
// Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose
|
||||
// file lists organization.change. It takes the same keys, runs the same write path and
|
||||
@@ -4857,34 +4766,6 @@ export class Broker {
|
||||
throw this.#refused(s, e, request);
|
||||
}
|
||||
}
|
||||
// Row 298 (#1804, Q228, sage-3594 Q3): the chief's grant scope, read and written by the human at the
|
||||
// cli alone, and the write is the human's Approve of the change. #human is the one check, so an
|
||||
// agent, the chief, the tester and a reader refuse human-required (the Console's cap never gets
|
||||
// here). bindHuman binds only the business's human. The writer is the organization writer's
|
||||
// grantScope; ORGANIZATION_KEYS doesn't name grantScope, so organization.set and limits.set can't
|
||||
// write it. Outside the store transaction, as the organization branch.
|
||||
#grantScopeVerb(s, verb, a) {
|
||||
this.#human(s);
|
||||
if (!this.#organization) fail('not-configured');
|
||||
if (verb === 'grants.scope') keys(a, []);
|
||||
else keys(a, ['revision', 'scope'], ['revision', 'scope']);
|
||||
let result;
|
||||
try {
|
||||
result = this.#organization(s.business, verb, a, s.human);
|
||||
} catch (e) {
|
||||
this.#organizationFail(e, verb);
|
||||
}
|
||||
this.#secretCheck(result);
|
||||
// The change's record: the human proof bindHuman checked, and the old and new scopes' sha256.
|
||||
// The writer's changes.jsonl entry holds the values.
|
||||
if (verb === 'grants.scope.set')
|
||||
this.#store.transaction(() => this.#grantEvent(s.business, {
|
||||
action: 'grant.scope', by: s.human, via: s.via, outsideAgent: s.outsideAgent === true,
|
||||
oldSha256: sha256(JSON.stringify(result.old)), newSha256: sha256(JSON.stringify(result.scope)),
|
||||
transaction: result.transaction ?? null,
|
||||
}));
|
||||
return result;
|
||||
}
|
||||
// Row 199 (#1697, design section 6). The token of an issue comes in args from the human's stdin; the
|
||||
// secret check above has refused one the bus already holds, a refusal records its code alone, and
|
||||
// nothing returned carries it. Outside the store transaction, as the organization branch.
|
||||
|
||||
@@ -323,8 +323,6 @@ export async function startBroker({
|
||||
if(verb==='instances')return instances.read(id,actor);
|
||||
// Row 65 (mos-0840): the Console's routing table.
|
||||
if(verb==='routes')return settings.routes(id);
|
||||
// Row 298 (#1804, Q228): the chief's grant scope as the file stands, the fallback shown.
|
||||
if(verb==='grants.scope')return instances.grantScopeRead(id);
|
||||
// Row 194 (#1692, V5): a delete's checks at the verb, with no write and no reload.
|
||||
if(verb==='instances.check')return instances.checkDelete(id,args.instance,actor);
|
||||
// Row 194 b6 (webby-0094 B1): a fire's checks against the file, before step A.
|
||||
@@ -336,8 +334,6 @@ export async function startBroker({
|
||||
else if(verb==='plugins.set')result=plugins.set(id,args,actor);
|
||||
// Row 194 (#1692, V6): the human's keep and noHire lists, through the instance writer.
|
||||
else if(verb==='veto.set')result=instances.veto(id,args,actor);
|
||||
// Row 298: the human's grant scope, through the instance writer.
|
||||
else if(verb==='grants.scope.set')result=instances.grantScope(id,args,actor);
|
||||
// Row 194 (D2.1, D2.2): the chief's hire and fire, with the reason in the change context.
|
||||
else if(verb==='instance.hire')result=instances.hire(id,args,{actor,context});
|
||||
else if(verb==='instance.fire')result=instances.fire(id,{name:args.name},{actor,context});
|
||||
@@ -369,13 +365,6 @@ export async function startBroker({
|
||||
return false;
|
||||
}
|
||||
},
|
||||
// Row 298 (#1804, Q228): one read binds launch.grantScope to the organization revision.
|
||||
// A missing organization reader or unreadable file cannot authorize an automatic grant.
|
||||
grantScope: (id) => {
|
||||
if (!settings) return { value: undefined, revision: null };
|
||||
const { document, revision } = instances.writer.read(id);
|
||||
return { value: document.launch?.grantScope, revision };
|
||||
},
|
||||
// Row 194 (#1692, D4.1, B8): bus.tester as the file sets it now; an unreadable file is off.
|
||||
testerOn: (id) => {
|
||||
try {
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
// 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.
|
||||
// Row 225 (C2-10b) re-pins webui bus.mjs for one STATUS entry, library-revert-not-agent: 409; no proof
|
||||
// or transport change.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
@@ -29,7 +31,7 @@ const baselines = {
|
||||
// Row 194 (#1692, brief item 7, sage-1610 a): organization-tracker-required 400 and
|
||||
// organization-tracker-bot-taken 409; no proof or transport change.
|
||||
// Row 301 (#1808, mos-1449): the user transport moved from user-bus.mjs into bus.mjs; humanCall unchanged.
|
||||
"packages/webui/src/bus.mjs": "604092cb706b152d458f299ff1071f6cd488774629012f96b672f7e9a16c0b2c",
|
||||
"packages/webui/src/bus.mjs": "9f33e9ca4feb7265ae33fd3ddf0ead0b75375d07ecbe9d8e55c618d29edea658",
|
||||
};
|
||||
const repoFile = path => new URL("../../../" + path, import.meta.url);
|
||||
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
// Row 298: the real runtime routes the human scope verb to the durable
|
||||
// organization writer. Everything lives under a scratch config/data root.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { startBroker } from '../src/runtime.mjs';
|
||||
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
|
||||
|
||||
// Break caught: missing runtime setter routing or losing the organization receipt.
|
||||
test('row 298 runtime: human scope approval writes the file and organization history at the supplied revision', async t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'scope-runtime-'));
|
||||
let runtime;
|
||||
t.after(async () => { await runtime?.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const rolesDir = rolesCopy(root);
|
||||
const file = writeJson(join(root, 'businesses/demo.json'), businessDoc(root, 'demo'));
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
runtime = await startBroker({ dataRoot: join(root, 'data'),
|
||||
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } },
|
||||
organization: { dir: root, rolesDir, system: systemFor(root) },
|
||||
});
|
||||
const human = runtime.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const call = (verb, args = {}) => runtime.broker.request(human, { verb, args });
|
||||
const before = call('grants.scope');
|
||||
assert.deepEqual(before.scope, {});
|
||||
const scope = { gitea: { agents: ['coder'], projects: ['stack'] } };
|
||||
const saved = call('grants.scope.set', { revision: before.revision, scope });
|
||||
assert.deepEqual(JSON.parse(readFileSync(file, 'utf8')).launch.grantScope, scope);
|
||||
assert.deepEqual(call('grants.scope').scope, scope);
|
||||
assert.notEqual(saved.revision, before.revision);
|
||||
const entries = readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
|
||||
assert.equal(entries.at(-1).state, 'committed');
|
||||
assert.equal(entries.at(-1).actor, 'jason');
|
||||
assert.equal(entries.at(-1).changes[0].path, 'launch.grantScope');
|
||||
const bytes = readFileSync(file, 'utf8');
|
||||
assert.throws(() => call('grants.scope.set', { revision: before.revision, scope: {} }), e => e.code === 'revision-conflict');
|
||||
assert.equal(readFileSync(file, 'utf8'), bytes);
|
||||
});
|
||||
@@ -1,359 +0,0 @@
|
||||
// Row 298 (#1804, Q228): the chief of staff's grant scope at the broker. The human's grants.scope and
|
||||
// grants.scope.set (sage-3594 Q3), the in-scope close via 'scope' with grant.scope.used (Q4), the
|
||||
// spend recheck's scope-changed (Q5), and the out-of-scope raise that still waits for the human.
|
||||
// Gitea grants run against the fake Gitea in fake-gitea-api.mjs on 127.0.0.1; host grants stop at the
|
||||
// spend, as no host key store is configured, and nothing connects to a host. Every token is a fake the
|
||||
// fake made and none is printed. The organization writer is a fake here; the real one is
|
||||
// packages/business/tests/grant-scope.test.mjs.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Broker, GRANT_EVIDENCE, writeGrantEvent } from '../src/broker.mjs';
|
||||
import { Credentials } from '../src/credentials.mjs';
|
||||
import { giteaGrant, giteaKeys } from '../src/gitea-grant.mjs';
|
||||
import { hostGrant, grantKeys, fingerprint } from '../src/host-grant.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { checkGrantScope, grantScopeOf } from '../../business/src/vocabulary.mjs';
|
||||
import { fakeGiteaApi } from './fake-gitea-api.mjs';
|
||||
import { hostRecord, grantDecision } from './host-fixtures.mjs';
|
||||
|
||||
const USER = 'demo-coder';
|
||||
const ENTRY = { address: '192.0.2.10', port: 22, user: 'jarvis', hostKeys: ['ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHostKeyForTests'] };
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const sha256 = (x) => createHash('sha256').update(x).digest('hex');
|
||||
const dayFrom = (n) => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10);
|
||||
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
|
||||
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
|
||||
const DEFINITIONS = {
|
||||
'chief-of-staff': { roleVersion: 2, credentials: [] },
|
||||
pm: { roleVersion: 2, credentials: [] },
|
||||
coder: { roleVersion: 2, credentials: [{ service: 'gitea' }] },
|
||||
ops: { roleVersion: 2, credentials: [{ service: 'host' }] },
|
||||
};
|
||||
const GITEA = { gitea: { agents: ['coder'], projects: ['app'] } };
|
||||
const HOST = { host: { agents: ['ops'], hosts: ['web1'], commands: [['systemctl', 'status', 'nginx']] } };
|
||||
const REVISION = 'a'.repeat(64);
|
||||
async function until(fn, ms = 10000) {
|
||||
const end = Date.now() + ms;
|
||||
while (!fn()) {
|
||||
if (Date.now() > end) throw new Error('timed out');
|
||||
await sleep(20);
|
||||
}
|
||||
}
|
||||
const rejects = (p, code) => assert.rejects(Promise.resolve().then(p), (e) => (assert.equal(e.code, code), true));
|
||||
|
||||
// opts.scope: the value grantScope answers, or a function called at each read.
|
||||
async function setup(t, opts = {}) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'g298-'));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const gitea = await fakeGiteaApi(t, join(dir, 'tls'));
|
||||
gitea.addUser('acme');
|
||||
gitea.addRepo('acme', 'app');
|
||||
gitea.addRepo('acme', 'lib');
|
||||
const configDir = join(dir, 'cfg');
|
||||
mkdirSync(configDir, { mode: 0o700 });
|
||||
const storeRoot = join(configDir, 'secrets', 'agents');
|
||||
mkdirSync(join(dir, 'data'), { mode: 0o700 });
|
||||
const store = new Store(join(dir, 'data'));
|
||||
const credentials = new Credentials({ references: {}, storeRoot, dataRoot: join(dir, 'data') });
|
||||
t.after(() => {
|
||||
credentials.close();
|
||||
store.close();
|
||||
});
|
||||
const business = {
|
||||
id: 'demo',
|
||||
human: 'jason',
|
||||
arbiters: { technical: 'pm', delivery: 'pm' },
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', ...none },
|
||||
// A second chief-of-staff instance, as an instances.set or the tester could add. It isn't launch.by.
|
||||
chief2: { definition: 'chief-of-staff', ...none },
|
||||
pm: { definition: 'pm', ...none },
|
||||
coder: { definition: 'coder', ...none, identity: { name: 'Coder', email: '[email protected]', gitea: { url: gitea.url, user: USER } }, publish: ['app', 'lib'] },
|
||||
ops: { definition: 'ops', ...none },
|
||||
},
|
||||
definitions: structuredClone(DEFINITIONS),
|
||||
projects: Object.fromEntries(['app', 'lib'].map((p) => [p, { remote: `${gitea.url}/acme/${p}` }])),
|
||||
launch: { by: 'chief', instances: ['pm', 'coder', 'ops'] },
|
||||
vars: { 'bus.tester': true },
|
||||
};
|
||||
const grantApi = giteaGrant({ configDir, ca: gitea.ca, timeoutMs: 5000 });
|
||||
grantApi.writeAdmin('demo', gitea.adminToken, { url: gitea.url, account: 'mosaic-grant', rotateBy: dayFrom(60), setAt: '2026-10-21T00:00:00.000Z' });
|
||||
// The fake organization writer: the scope in a box, checked as the real writer checks it.
|
||||
const box = { value: opts.scope };
|
||||
const orgCalls = [];
|
||||
const organization = (id, verb, args, actor) => {
|
||||
orgCalls.push([verb, actor]);
|
||||
if (verb === 'grants.scope') return { business: id, revision: REVISION, ...grantScopeOf(box.value) };
|
||||
if (verb === 'grants.scope.set') {
|
||||
const scope = checkGrantScope(args.scope);
|
||||
if (scope === null) throw Object.assign(new Error('scope is not a grant scope'), { code: 'invalid-request' });
|
||||
const old = grantScopeOf(box.value).scope;
|
||||
box.value = scope;
|
||||
return { business: id, scope, old, transaction: 'tx-298' };
|
||||
}
|
||||
return { revision: REVISION };
|
||||
};
|
||||
// Keep the real key generation/store/records; replace only remote host calls.
|
||||
const hostLines = new Set();
|
||||
const hostApi = {
|
||||
...hostGrant({ configDir, runRoot: join(configDir, 'secrets', 'run') }),
|
||||
keyscan: async () => ENTRY.hostKeys,
|
||||
install: async (_business, _host, _record, tag) => { hostLines.add(tag); },
|
||||
verify: async () => {},
|
||||
remove: async (_business, _host, _record, tag) => { hostLines.delete(tag); },
|
||||
};
|
||||
const broker = new Broker({
|
||||
store,
|
||||
businesses: { demo: business },
|
||||
secretCheck: (value) => credentials.assertClean(value),
|
||||
giteaGrant: grantApi,
|
||||
giteaKeys: giteaKeys({ credentials, storeRoot }),
|
||||
hostGrant: hostApi,
|
||||
hostKeys: opts.hostKeys ? grantKeys({ credentials, storeRoot }) : null,
|
||||
organization,
|
||||
grantScope: () => ({ value: typeof box.value === 'function' ? box.value() : box.value, revision: Object.hasOwn(opts, 'revision') ? opts.revision : REVISION }),
|
||||
});
|
||||
const caps = {};
|
||||
for (const role of ['chief', 'chief2', 'pm', 'ops']) {
|
||||
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: { provider: 'p', iss: 'https://idp.example.test', sub: 's' } });
|
||||
hostRecord(store, { host: 'web1', entry: ENTRY });
|
||||
const call = (who, verb, args = {}) => broker.request(caps[who], { verb, args });
|
||||
const raiseGitea = (over = {}, who = 'chief') =>
|
||||
call(who, 'decision.raise', {
|
||||
action: 'credential.mint', question: 'Grant coder Gitea?', options, recommendation: 'yes', blocking: false,
|
||||
grant: { method: 'gitea', agent: 'coder', target: { projects: ['app'] }, rotateBy: dayFrom(30), ...over },
|
||||
});
|
||||
const raiseHost = (over = {}, who = 'chief') =>
|
||||
call(who, 'decision.raise', {
|
||||
action: 'credential.mint', question: 'Grant ops web1?', options, recommendation: 'yes', blocking: false,
|
||||
grant: { method: 'host', agent: 'ops', host: 'web1', commands: [['systemctl', 'status', 'nginx']], rotateBy: dayFrom(30), ...over },
|
||||
});
|
||||
const grantEvents = () =>
|
||||
store.all(`SELECT body FROM events WHERE business='demo' AND ${GRANT_EVIDENCE} ORDER BY seq`).map((e) => JSON.parse(e.body));
|
||||
const ended = (decision) => grantEvents().findLast((e) => e.grantDecision === decision && ['grant.applied', 'grant.failed'].includes(e.action));
|
||||
const closes = (decision) => store.all('SELECT * FROM decision_events WHERE decision=? ORDER BY seq', decision);
|
||||
const spent = (decision) => grantEvents().filter((e) => e.decision === decision && e.operation?.startsWith('grant.')).map((e) => e.operation);
|
||||
const record = () => store.get("SELECT * FROM gitea_grants WHERE business='demo' AND agent='coder'");
|
||||
return { store, broker, caps, box, orgCalls, call, raiseGitea, raiseHost, grantEvents, ended, closes, spent, record, gitea, hostLines };
|
||||
}
|
||||
|
||||
test('row 298 (Q3): only the human at the cli reads or sets the scope; agents, the tester, a reader and the Console refuse', async (t) => {
|
||||
const f = await setup(t);
|
||||
for (const verb of ['grants.scope', 'grants.scope.set']) {
|
||||
const args = verb === 'grants.scope' ? {} : { revision: REVISION, scope: GITEA };
|
||||
for (const who of ['chief', 'chief2', 'pm', 'tester']) await rejects(() => f.call(who, verb, args), 'human-required');
|
||||
await rejects(() => f.call('reader', verb, args), 'read-only');
|
||||
await rejects(() => f.call('console', verb, args), 'human-required');
|
||||
}
|
||||
assert.deepEqual(f.orgCalls, []);
|
||||
assert.equal(f.box.value, undefined);
|
||||
assert.deepEqual(f.grantEvents().filter((e) => e.action === 'grant.scope'), []);
|
||||
});
|
||||
|
||||
test('row 298 (Q1): organization.set and limits.set refuse a grantScope key, and nothing is written', async (t) => {
|
||||
const f = await setup(t);
|
||||
await rejects(() => f.call('human', 'limits.set', { revision: REVISION, grantScope: GITEA }), 'invalid-request');
|
||||
await rejects(() => f.call('chief', 'organization.set', { revision: REVISION, grantScope: GITEA }), 'invalid-request');
|
||||
assert.deepEqual(f.orgCalls, []);
|
||||
assert.equal(f.box.value, undefined);
|
||||
});
|
||||
|
||||
test('row 298 (Q3): the human verb writes the scope and a grant.scope record with the human proof and both sha256', async (t) => {
|
||||
const f = await setup(t);
|
||||
assert.deepEqual(await f.call('human', 'grants.scope'), { business: 'demo', revision: REVISION, scope: {}, fallback: false });
|
||||
const r = await f.call('human', 'grants.scope.set', { revision: REVISION, scope: GITEA });
|
||||
assert.deepEqual(r.scope, GITEA);
|
||||
assert.deepEqual(f.orgCalls, [['grants.scope', 'jason'], ['grants.scope.set', 'jason']]);
|
||||
const changes = f.grantEvents().filter((e) => e.action === 'grant.scope');
|
||||
assert.deepEqual(changes, [{
|
||||
action: 'grant.scope', by: 'jason', via: 'cli', outsideAgent: true,
|
||||
oldSha256: sha256(JSON.stringify({})), newSha256: sha256(JSON.stringify(GITEA)), transaction: 'tx-298',
|
||||
}]);
|
||||
await rejects(() => f.call('human', 'grants.scope.set', { revision: REVISION, scope: { gitea: {} } }), 'invalid-request');
|
||||
await rejects(() => f.call('human', 'grants.scope.set', { revision: REVISION }), 'invalid-request');
|
||||
await rejects(() => f.call('human', 'grants.scope.set', { revision: REVISION, scope: {}, extra: 1 }), 'invalid-request');
|
||||
assert.equal(f.grantEvents().filter((e) => e.action === 'grant.scope').length, 1);
|
||||
});
|
||||
|
||||
test('row 298 (Q4): an in-scope Gitea grant is issued with no inbox item, logged with 236 records, and revocable', async (t) => {
|
||||
const f = await setup(t, { scope: GITEA });
|
||||
const d = await f.raiseGitea();
|
||||
const [close] = f.closes(d.id);
|
||||
assert.deepEqual([close.op, close.by, close.choice, close.via], ['resolved', 'grant-scope', 'yes', 'scope']);
|
||||
assert.ok(f.grantEvents().some(e => e.action === 'grant.scope.used' && e.grantDecision === d.id), 'scope close must have protected evidence');
|
||||
const inbox = await f.call('human', 'inbox');
|
||||
assert.ok(!JSON.stringify(inbox).includes(d.id), 'the decision is not in the human inbox');
|
||||
await until(() => f.ended(d.id));
|
||||
await f.broker.giteaIdle();
|
||||
assert.equal(f.ended(d.id).action, 'grant.applied');
|
||||
const used = f.grantEvents().find((e) => e.action === 'grant.scope.used');
|
||||
assert.deepEqual(used, {
|
||||
action: 'grant.scope.used', grantDecision: d.id, decisionSeq: close.seq, method: 'gitea', agent: 'coder', scope: GITEA, revision: REVISION, scopeSha256: sha256(JSON.stringify(GITEA)),
|
||||
});
|
||||
assert.deepEqual(f.spent(d.id), ['grant.gitea']);
|
||||
assert.equal(f.record().decision, d.id);
|
||||
// The spend is row 236's: a second start finds it consumed.
|
||||
assert.equal(await f.broker.runGiteaGrant('demo', d.id), 'decision-consumed');
|
||||
const out = await f.call('human', 'grants.revoke', { service: 'gitea', agent: 'coder', reason: 'test' });
|
||||
assert.deepEqual(out.serviceLeft, []);
|
||||
assert.ok(f.grantEvents().some((e) => e.action === 'grant.revoked' && e.agent === 'coder'));
|
||||
assert.equal(f.record(), undefined);
|
||||
});
|
||||
|
||||
test('row 298: an out-of-scope Gitea grant waits for the human, whose Approve still issues it', async (t) => {
|
||||
const f = await setup(t, { scope: GITEA });
|
||||
const d = await f.raiseGitea({ target: { projects: ['app', 'lib'] } });
|
||||
assert.deepEqual(f.closes(d.id), []);
|
||||
assert.ok(JSON.stringify(await f.call('human', 'inbox')).includes(d.id));
|
||||
assert.deepEqual(f.grantEvents().filter((e) => e.action === 'grant.scope.used'), []);
|
||||
await f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
await until(() => f.ended(d.id));
|
||||
await f.broker.giteaIdle();
|
||||
assert.equal(f.ended(d.id).action, 'grant.applied');
|
||||
});
|
||||
|
||||
test('row 298: with no scope, a bad scope or an unreadable file, an in-scope-looking grant waits', async (t) => {
|
||||
const bad = { gitea: { agents: ['coder'], projects: ['app'], note: 'not a key' } };
|
||||
for (const scope of [undefined, bad, () => { throw new Error('unreadable'); }]) {
|
||||
const f = await setup(t, { scope });
|
||||
const d = await f.raiseGitea();
|
||||
assert.deepEqual(f.closes(d.id), [], String(scope));
|
||||
assert.deepEqual(f.grantEvents().filter((e) => e.action === 'grant.scope.used'), []);
|
||||
}
|
||||
});
|
||||
|
||||
test('row 298 (Q6): a chief-of-staff instance that is not launch.by never raises in scope', async (t) => {
|
||||
const f = await setup(t, { scope: GITEA });
|
||||
const d = await f.raiseGitea({}, 'chief2');
|
||||
assert.deepEqual(f.closes(d.id), []);
|
||||
});
|
||||
|
||||
test('row 298 (Q5): a scope narrowed between the raise and the spend ends the grant scope-changed', async (t) => {
|
||||
let reads = 0;
|
||||
const f = await setup(t, { scope: () => (reads++ === 0 ? GITEA : {}) });
|
||||
const d = await f.raiseGitea();
|
||||
assert.equal(f.closes(d.id)[0].via, 'scope');
|
||||
await until(() => f.ended(d.id));
|
||||
await f.broker.giteaIdle();
|
||||
assert.deepEqual([f.ended(d.id).action, f.ended(d.id).code], ['grant.failed', 'scope-changed']);
|
||||
assert.equal(f.record(), undefined);
|
||||
assert.deepEqual(f.gitea.tokens(USER) ?? [], []);
|
||||
assert.deepEqual(f.spent(d.id), ['grant.gitea']);
|
||||
assert.equal(await f.broker.runGiteaGrant('demo', d.id), 'decision-consumed');
|
||||
});
|
||||
|
||||
test('row 298 (Q2): an in-scope host grant is spent with no Approve; ls never covers lsblk, nor a scope command extra arguments', async (t) => {
|
||||
const scope = { host: { agents: ['ops'], hosts: ['web1'], commands: [['ls'], ['systemctl', 'status', 'nginx']] } };
|
||||
const f = await setup(t, { scope });
|
||||
const d = await f.raiseHost();
|
||||
assert.equal(f.closes(d.id)[0].via, 'scope');
|
||||
await until(() => f.ended(d.id));
|
||||
// The spend is row 235's. No host key store is configured, so the recheck after it ends here.
|
||||
assert.deepEqual(f.spent(d.id), ['grant.host']);
|
||||
assert.deepEqual([f.ended(d.id).action, f.ended(d.id).code], ['grant.failed', 'host-not-configured']);
|
||||
for (const commands of [[['lsblk']], [['ls', '-l']], [['systemctl', 'restart']]]) {
|
||||
const w = await f.raiseHost({ commands });
|
||||
assert.deepEqual(f.closes(w.id), [], JSON.stringify(commands));
|
||||
}
|
||||
});
|
||||
|
||||
test('row 298 (Q5): a host scope narrowed before the spend ends scope-changed', async (t) => {
|
||||
let reads = 0;
|
||||
const f = await setup(t, { scope: () => (reads++ === 0 ? HOST : {}) });
|
||||
const d = await f.raiseHost();
|
||||
assert.equal(f.closes(d.id)[0].via, 'scope');
|
||||
await until(() => f.ended(d.id));
|
||||
assert.deepEqual([f.ended(d.id).action, f.ended(d.id).code], ['grant.failed', 'scope-changed']);
|
||||
assert.deepEqual(f.spent(d.id), ['grant.host']);
|
||||
});
|
||||
|
||||
// Break caught: accepting scope closes with no evidence or unbound evidence.
|
||||
test('row 298 Q4: missing scope evidence or a bad scope digest cannot authorize execution', async (t) => {
|
||||
const f = await setup(t, { scope: GITEA });
|
||||
const good = { scope: GITEA, scopeSha256: sha256(JSON.stringify(GITEA)), revision: REVISION };
|
||||
for (const evidence of [
|
||||
null,
|
||||
{ ...good, scopeSha256: '0'.repeat(64) },
|
||||
{ ...good, revision: null },
|
||||
{ ...good, scope: { gitea: 'all' } },
|
||||
{ ...good, agent: 'other' },
|
||||
{ ...good, method: 'host' },
|
||||
{ ...good, decisionSeq: 999999 },
|
||||
]) {
|
||||
const grant = { method: 'gitea', agent: 'coder', target: { projects: ['app'] }, rotateBy: dayFrom(30) };
|
||||
const id = grantDecision(f.store, { grant, close: { op: 'resolved', by: 'grant-scope', choice: 'yes', via: 'scope' } });
|
||||
if (evidence) writeGrantEvent(f.store, new Date().toISOString(), 'demo', {
|
||||
action: 'grant.scope.used', grantDecision: id, decisionSeq: f.closes(id)[0].seq,
|
||||
method: 'gitea', agent: 'coder', ...evidence,
|
||||
}, id);
|
||||
assert.equal(await f.broker.runGiteaGrant('demo', id), 'not-authorized');
|
||||
assert.deepEqual(f.spent(id), []);
|
||||
assert.equal(f.record(), undefined);
|
||||
}
|
||||
// Positive control: the same fixture with valid scope evidence reaches the
|
||||
// next grant check. Its intentionally absent definition binding refuses there.
|
||||
const id = grantDecision(f.store, {
|
||||
grant: { method: 'gitea', agent: 'coder', target: { projects: ['app'] }, rotateBy: dayFrom(30) },
|
||||
close: { op: 'resolved', by: 'grant-scope', choice: 'yes', via: 'scope' },
|
||||
});
|
||||
writeGrantEvent(f.store, new Date().toISOString(), 'demo', {
|
||||
action: 'grant.scope.used', grantDecision: id, decisionSeq: f.closes(id)[0].seq,
|
||||
method: 'gitea', agent: 'coder', ...good,
|
||||
}, id);
|
||||
assert.equal(await f.broker.runGiteaGrant('demo', id), 'decision-mismatch');
|
||||
assert.deepEqual(f.spent(id), []);
|
||||
assert.equal(f.record(), undefined);
|
||||
});
|
||||
|
||||
// Break caught: host grants bypassing records or losing the ordinary revoke path.
|
||||
test('row 298 T4/T8: an in-scope host grant issues, records and revokes through the existing executor', async (t) => {
|
||||
const f = await setup(t, { scope: HOST, hostKeys: true });
|
||||
const d = await f.raiseHost();
|
||||
assert.equal(f.closes(d.id)[0].via, 'scope');
|
||||
assert.ok(!JSON.stringify(await f.call('human', 'inbox')).includes(d.id));
|
||||
await until(() => f.ended(d.id));
|
||||
await f.broker.grantsIdle();
|
||||
assert.equal(f.ended(d.id).action, 'grant.applied');
|
||||
assert.equal(f.store.get("SELECT decision FROM host_grants WHERE agent='ops'").decision, d.id);
|
||||
assert.equal(f.hostLines.size, 1);
|
||||
await f.call('human', 'grants.scope.set', { revision: REVISION, scope: {} });
|
||||
// Historical approval still passes #hostAccess after scope is narrowed;
|
||||
// only the absent command executor refuses this call, before any sudo decision.
|
||||
await rejects(() => f.broker.requestHost(f.caps.ops,
|
||||
{ verb: 'host.run', args: { host: 'web1', argv: ['systemctl', 'status', 'nginx'] } }, null), 'host-not-configured');
|
||||
await f.call('human', 'hosts.revoke', { agent: 'ops', host: 'web1', reason: 'test' });
|
||||
assert.equal(f.store.get("SELECT decision FROM host_grants WHERE agent='ops'"), undefined);
|
||||
assert.equal(f.hostLines.size, 0);
|
||||
assert.ok(f.grantEvents().some(e => e.action === 'grant.revoked' && e.agent === 'ops'));
|
||||
});
|
||||
|
||||
// Break caught: a new host enrollment entering the scope shortcut.
|
||||
test('row 298 T6: host enrollment stays open for human Approve even with both methods in scope', async (t) => {
|
||||
const f = await setup(t, { scope: { ...GITEA, ...HOST } });
|
||||
const d = await f.call('chief', 'decision.raise', {
|
||||
action: 'credential.mint', question: 'Enroll host?', options, recommendation: 'yes', blocking: false,
|
||||
grant: { method: 'host-enroll', host: 'web2', address: ENTRY.address, port: ENTRY.port,
|
||||
user: ENTRY.user, sudo: true, fingerprints: ENTRY.hostKeys.map(fingerprint) },
|
||||
});
|
||||
assert.deepEqual(f.closes(d.id), []);
|
||||
assert.ok(JSON.stringify(await f.call('human', 'inbox')).includes(d.id));
|
||||
assert.deepEqual(f.grantEvents().filter(e => e.action === 'grant.scope.used'), []);
|
||||
});
|
||||
|
||||
// Break caught: using a scope without a verifiable organization revision.
|
||||
test('row 298: scope without an organization revision never closes automatically', async (t) => {
|
||||
const f = await setup(t, { scope: GITEA, revision: null });
|
||||
const d = await f.raiseGitea();
|
||||
assert.deepEqual(f.closes(d.id), []);
|
||||
assert.deepEqual(f.spent(d.id), []);
|
||||
});
|
||||
@@ -33,7 +33,7 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
|
||||
// Row 236 (#1742, #436b) reads giteaName and parseGiteaUrl for the Gitea grant path.
|
||||
// FED R3 r5 (row 274, #414) reads covers, a user's grant check, from it.
|
||||
// Row 219 reads THREAD_DEFINITIONS from the same vocabulary for creative thread admission.
|
||||
"import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../business/src/vocabulary.mjs';",
|
||||
"import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';",
|
||||
]);
|
||||
assert.doesNotMatch(source, /\bimport\s*\(/);
|
||||
assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/);
|
||||
|
||||
@@ -175,7 +175,7 @@ function checkLaunch(launch, roles, definitions, file) {
|
||||
const where = `${file} launch`;
|
||||
requireObject(launch, where);
|
||||
if (Object.hasOwn(launch,"max")) refuse(`${where}.max needs migration: mosaic limits migrate --file FILE --models FILE`);
|
||||
rejectUnknownKeys(launch, ["by", "instances", "models", "limits", "threadIdleSeconds", "account", "keep", "noHire", "grantScope"], where);
|
||||
rejectUnknownKeys(launch, ["by", "instances", "models", "limits", "threadIdleSeconds", "account", "keep", "noHire"], where);
|
||||
requireId(launch.by, `${where}.by`);
|
||||
if (!Object.hasOwn(roles, launch.by)) refuse(`${where}.by names ${launch.by}, which the business file doesn't declare`);
|
||||
const launcher = definitions[roles[launch.by].definition];
|
||||
@@ -212,9 +212,6 @@ function checkLaunch(launch, roles, definitions, file) {
|
||||
return {
|
||||
by: launch.by, instances, models, limits, threadIdleSeconds, ...(account ? { account } : {}),
|
||||
...(keep ? { keep } : {}), ...(noHire ? { noHire } : {}),
|
||||
// Row 298 (#1804, Q228): the chief's grant scope passes through unchecked. A bad value must not
|
||||
// refuse the whole file; the broker's reader (grantScopeOf) falls back to the empty scope instead.
|
||||
...(launch.grantScope !== undefined ? { grantScope: structuredClone(launch.grantScope) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,12 +14,11 @@ import { resolveInstance } from "./resolve.mjs";
|
||||
import { loadRole } from "./role.mjs";
|
||||
import { refuse } from "./errors.mjs";
|
||||
import { isPlainObject, canonicalJson } from "./util.mjs";
|
||||
import { CREATIVE_DEFINITIONS, END_PROTECTED, HOST_SERVICE, ID_PATTERN, checkGrantScope, grantScopeOf } from "./vocabulary.mjs";
|
||||
import { CREATIVE_DEFINITIONS, END_PROTECTED, HOST_SERVICE, ID_PATTERN } from "./vocabulary.mjs";
|
||||
|
||||
const SET_KEYS = ["revision", "instance", "op", "definition", "vars", "launch", "tracker"];
|
||||
const VETO_KEYS = ["revision", "op", "name"];
|
||||
const VETO_OPS = ["keep", "unkeep", "nohire", "hire"];
|
||||
const SCOPE_KEYS = ["revision", "scope"];
|
||||
const id = value => typeof value === "string" && ID_PATTERN.test(value);
|
||||
const revisionOk = value => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
||||
// Row 194 (D2.2, N3): the vars a hire may set, the ones the chief can already set on an instance.
|
||||
@@ -303,32 +302,6 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
|
||||
return commit(business, old, next, { revision: args.revision, actor });
|
||||
}
|
||||
|
||||
// Row 298 (#1804, Q228): the chief's grant scope as the file stands. scope is the one the broker
|
||||
// applies, so a bad value reads as {} with fallback true.
|
||||
function grantScopeRead(business) {
|
||||
const { document, revision } = writer.read(business);
|
||||
if (!document.launch) throw new OrganizationError("organization-launch-not-configured");
|
||||
return { business, revision, ...grantScopeOf(document.launch.grantScope) };
|
||||
}
|
||||
|
||||
// Row 298: args { revision, scope }. Human-only at the broker (grants.scope.set), and the only
|
||||
// writer of launch.grantScope: SET_KEYS and the organization.set keys don't name it. The scope is
|
||||
// checked strictly here; {} removes the key. old is what the broker applied before the write,
|
||||
// the fallback included.
|
||||
function grantScope(business, args, actor) {
|
||||
if (!isPlainObject(args) || Object.keys(args).some(key => !SCOPE_KEYS.includes(key))) throw new OrganizationError("invalid-request");
|
||||
if (!revisionOk(args.revision)) throw new OrganizationError("invalid-request");
|
||||
const scope = checkGrantScope(args.scope);
|
||||
if (scope === null) throw new OrganizationError("invalid-request", "scope is not a grant scope");
|
||||
const old = writer.read(business).document;
|
||||
if (!old.launch) throw new OrganizationError("organization-launch-not-configured");
|
||||
const next = structuredClone(old);
|
||||
if (Object.keys(scope).length === 0) delete next.launch.grantScope;
|
||||
else next.launch.grantScope = scope;
|
||||
const before = grantScopeOf(old.launch.grantScope).scope;
|
||||
return { ...commit(business, old, next, { revision: args.revision, actor }), scope, old: before };
|
||||
}
|
||||
|
||||
// Row 194 (#1692, D2.2): the chief's hire, after the broker's identity, grant, allowlist and
|
||||
// hireable checks. args { name, definition, vars? }. A new name is added and listed. A fired name
|
||||
// (declared, not listed) is listed again only with its own definition and no vars. The writer
|
||||
@@ -446,5 +419,5 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
|
||||
return { ...read(business, actor), transaction: receipt.transaction, restart: false };
|
||||
}
|
||||
|
||||
return { writer, read, set, checkDelete, checkFire, veto, hire, fire, grantScope, grantScopeRead };
|
||||
return { writer, read, set, checkDelete, checkFire, veto, hire, fire };
|
||||
}
|
||||
@@ -299,73 +299,3 @@ export function covers(grant, { business, project, instance }, now, { level } =
|
||||
if (!LEVELS.includes(grant.level) || LEVELS.indexOf(grant.level) > LEVELS.indexOf(level)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Row 298 (#1804, Q228): the chief of staff's grant scope, launch.grantScope in the organization
|
||||
// file. Only the human writes it (grants.scope.set). A Gitea or host grant the enrolled chief raises
|
||||
// inside it is issued with no per-grant Approve; anything else waits for the human as before. The
|
||||
// broker and the writer read these, and they live here because the session sandbox mounts this file
|
||||
// (seat/src/sandbox.mjs RUNNER_FILES) and the runner loads broker.mjs.
|
||||
//
|
||||
// Shape: { gitea?: { agents, projects }, host?: { agents, hosts, commands } }.
|
||||
// Each list is distinct and nonempty. A commands entry covers a requested prefix equal to it,
|
||||
// token for token and with equal length. No entry matches part of a token, so
|
||||
// ["ls"] never covers ["lsblk"]. Enrollment (host-enroll) is never in scope. {} is the empty scope.
|
||||
export const GRANT_SCOPE_EMPTY = Object.freeze({});
|
||||
const SCOPE_IDS = (value) =>
|
||||
Array.isArray(value) && value.length > 0 && value.length <= 256 &&
|
||||
value.every((x) => typeof x === "string" && ID_PATTERN.test(x)) && new Set(value).size === value.length;
|
||||
const SCOPE_ARGVS = (value) =>
|
||||
Array.isArray(value) && value.length > 0 && value.length <= 256 && value.every(validArgv) &&
|
||||
new Set(value.map((argv) => JSON.stringify(argv))).size === value.length;
|
||||
const SCOPE_PLAIN = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype;
|
||||
|
||||
// The scope, rebuilt in a fixed key order so JSON.stringify of it is stable, or null when the value
|
||||
// isn't one. The writer refuses null; the readers fall back (grantScopeOf).
|
||||
export function checkGrantScope(value) {
|
||||
if (!SCOPE_PLAIN(value) || Object.keys(value).some((k) => k !== "gitea" && k !== "host")) return null;
|
||||
const out = {};
|
||||
if (value.gitea !== undefined) {
|
||||
const g = value.gitea;
|
||||
if (!SCOPE_PLAIN(g) || Object.keys(g).some((k) => k !== "agents" && k !== "projects")) return null;
|
||||
if (!SCOPE_IDS(g.agents) || !SCOPE_IDS(g.projects)) return null;
|
||||
out.gitea = { agents: [...g.agents], projects: [...g.projects] };
|
||||
}
|
||||
if (value.host !== undefined) {
|
||||
const h = value.host;
|
||||
if (!SCOPE_PLAIN(h) || Object.keys(h).some((k) => !["agents", "hosts", "commands"].includes(k))) return null;
|
||||
if (!SCOPE_IDS(h.agents) || !Array.isArray(h.hosts) || !h.hosts.every((x) => typeof x === "string" && HOST_ID.test(x)) || !SCOPE_IDS(h.hosts)) return null;
|
||||
if (!SCOPE_ARGVS(h.commands)) return null;
|
||||
out.host = {
|
||||
agents: [...h.agents], hosts: [...h.hosts],
|
||||
commands: h.commands.map((argv) => [...argv]),
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// What the readers use: a missing key is the empty scope, and a bad value falls back to it, with
|
||||
// fallback true. The empty scope puts every grant in front of the human, so a bad value fails closed.
|
||||
export function grantScopeOf(value) {
|
||||
if (value === undefined) return { scope: GRANT_SCOPE_EMPTY, fallback: false };
|
||||
const scope = checkGrantScope(value);
|
||||
return scope === null ? { scope: GRANT_SCOPE_EMPTY, fallback: true } : { scope, fallback: false };
|
||||
}
|
||||
|
||||
// Whether a raise's grant body is inside a checked scope. Only gitea and host bodies can be.
|
||||
export function grantInScope(scope, body) {
|
||||
if (body?.method === "gitea") {
|
||||
const g = scope.gitea;
|
||||
return Boolean(g) && g.agents.includes(body.agent) &&
|
||||
Array.isArray(body.target?.projects) && body.target.projects.length > 0 &&
|
||||
body.target.projects.every((p) => g.projects.includes(p));
|
||||
}
|
||||
if (body?.method === "host") {
|
||||
const h = scope.host;
|
||||
const same = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]);
|
||||
return Boolean(h) && h.agents.includes(body.agent) && h.hosts.includes(body.host) &&
|
||||
Array.isArray(body.commands) && body.commands.length > 0 &&
|
||||
body.commands.every((argv) =>
|
||||
h.commands.some((c) => same(argv, c)));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
// Row 298 (#1804, Q228): the chief of staff's grant scope, launch.grantScope. The shape check, the
|
||||
// readers' fallback, the match rules (sage-3594 Q2) and the one writer. The broker's verbs, the
|
||||
// in-scope close and the executors are bus tests (packages/bus/tests/grant-scope.test.mjs).
|
||||
import test, { after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { instanceSettings } from "../src/instance-settings.mjs";
|
||||
import { launchSettings } from "../src/launch-settings.mjs";
|
||||
import { loadBusiness } from "../src/business.mjs";
|
||||
import { checkGrantScope, grantInScope, grantScopeOf } from "../src/vocabulary.mjs";
|
||||
import { businessDoc, rolesCopy, systemFor, tmp, writeJson } from "./helpers.mjs";
|
||||
|
||||
const roots = [];
|
||||
after(() => roots.forEach(root => rmSync(root, { recursive: true, force: true })));
|
||||
|
||||
const GITEA = { gitea: { agents: ["coder"], projects: ["app", "lib"] } };
|
||||
const HOST = { host: { agents: ["ops"], hosts: ["web1"], commands: [["systemctl", "status", "nginx"], ["ls"]] } };
|
||||
const gitea = (over = {}) => ({ method: "gitea", agent: "coder", target: { projects: ["app"] }, ...over });
|
||||
const host = (commands, over = {}) => ({ method: "host", agent: "ops", host: "web1", commands, ...over });
|
||||
|
||||
function fixture(mutate) {
|
||||
const root = tmp();
|
||||
roots.push(root);
|
||||
const rolesDir = rolesCopy(root);
|
||||
const doc = businessDoc(root, "acme");
|
||||
mutate?.(doc);
|
||||
const dir = join(root, "config");
|
||||
const file = writeJson(join(dir, "businesses", "acme.json"), doc);
|
||||
const settings = instanceSettings({ dir, rolesDir, system: systemFor(root) });
|
||||
const limits = launchSettings({ dir, rolesDir, system: systemFor(root) });
|
||||
const onDisk = () => JSON.parse(readFileSync(file, "utf8"));
|
||||
const log = () => readFileSync(settings.writer.logFile("acme"), "utf8").trim().split("\n").map(line => JSON.parse(line));
|
||||
const revision = () => settings.read("acme").revision;
|
||||
return { root, dir, rolesDir, file, settings, limits, onDisk, log, revision };
|
||||
}
|
||||
|
||||
test("row 298: checkGrantScope takes the D2 shape and refuses anything else", () => {
|
||||
assert.deepEqual(checkGrantScope({}), {});
|
||||
assert.deepEqual(checkGrantScope(GITEA), GITEA);
|
||||
assert.deepEqual(checkGrantScope(HOST), HOST);
|
||||
assert.deepEqual(checkGrantScope({ host: { agents: ["ops"], hosts: ["web1"], commands: [["uptime"]] } }).host.commands, [["uptime"]]);
|
||||
for (const bad of [
|
||||
null, [], "x", { other: {} }, { gitea: [] }, { gitea: { agents: ["coder"] } }, { gitea: { agents: [], projects: ["app"] } },
|
||||
{ gitea: { agents: ["coder", "coder"], projects: ["app"] } }, { gitea: { agents: ["Coder"], projects: ["app"] } },
|
||||
{ gitea: { agents: ["coder"], projects: ["app"], extra: 1 } }, { host: { agents: ["ops"], hosts: ["web1"] } },
|
||||
{ host: { agents: ["ops"], hosts: ["web_1"], commands: [["ls"]] } }, { host: { agents: ["ops"], hosts: ["web1"], commands: [[]] } },
|
||||
{ host: { agents: ["ops"], hosts: ["web1"], commands: [["ls"], ["ls"]] } }, { host: { agents: ["ops"], hosts: ["web1"], commands: [["bin/ls"]] } },
|
||||
{ host: { agents: ["ops"], hosts: ["web1"], commandPrefixes: [["ls"]] } }, { "host-enroll": {} },
|
||||
])
|
||||
assert.equal(checkGrantScope(bad), null, JSON.stringify(bad));
|
||||
});
|
||||
|
||||
test("row 298: a missing scope is empty and a bad one falls back to empty, which covers nothing", () => {
|
||||
assert.deepEqual(grantScopeOf(undefined), { scope: {}, fallback: false });
|
||||
assert.deepEqual(grantScopeOf(GITEA), { scope: GITEA, fallback: false });
|
||||
const bad = { gitea: { agents: ["coder"], projects: ["app"], extra: true } };
|
||||
assert.deepEqual(grantScopeOf(bad), { scope: {}, fallback: true });
|
||||
assert.equal(grantInScope(grantScopeOf(bad).scope, gitea()), false);
|
||||
assert.equal(grantInScope({}, gitea()), false);
|
||||
assert.equal(grantInScope({}, host([["ls"]])), false);
|
||||
});
|
||||
|
||||
test("row 298: a Gitea grant is in scope only when its agent and every project are listed", () => {
|
||||
assert.equal(grantInScope(GITEA, gitea()), true);
|
||||
assert.equal(grantInScope(GITEA, gitea({ target: { projects: ["app", "lib"] } })), true);
|
||||
assert.equal(grantInScope(GITEA, gitea({ target: { projects: ["app", "old"] } })), false);
|
||||
assert.equal(grantInScope(GITEA, gitea({ agent: "research" })), false);
|
||||
assert.equal(grantInScope(GITEA, gitea({ target: { projects: [] } })), false);
|
||||
assert.equal(grantInScope(HOST, gitea()), false);
|
||||
});
|
||||
|
||||
test("row 298 (sage-3594 Q2): host commands match whole argv tokens, and enrollment is never in scope", () => {
|
||||
assert.equal(grantInScope(HOST, host([["systemctl", "status", "nginx"]])), true);
|
||||
// A commands entry covers only itself: no extra trailing arguments, no shorter prefix.
|
||||
assert.equal(grantInScope(HOST, host([["systemctl", "status", "nginx", "--now"]])), false);
|
||||
assert.equal(grantInScope(HOST, host([["systemctl", "status"]])), false);
|
||||
// No scope entry permits extending the requested command prefix.
|
||||
assert.equal(grantInScope(HOST, host([["ls"]])), true);
|
||||
assert.equal(grantInScope(HOST, host([["ls", "-l", "/var"]])), false);
|
||||
assert.equal(grantInScope(HOST, host([["lsblk"]])), false);
|
||||
assert.equal(grantInScope(HOST, host([["ls"], ["lsblk"]])), false);
|
||||
assert.equal(grantInScope(HOST, host([["ls"]], { host: "web2" })), false);
|
||||
assert.equal(grantInScope(HOST, host([["ls"]], { agent: "coder" })), false);
|
||||
assert.equal(grantInScope(HOST, { method: "host-enroll", host: "web1" }), false);
|
||||
assert.equal(grantInScope({ ...HOST, "host-enroll": true }, { method: "host-enroll", host: "web1" }), false);
|
||||
});
|
||||
|
||||
test("row 298: a bad grantScope in the file still loads, and the read shows the fallback", () => {
|
||||
const f = fixture(doc => { doc.launch.grantScope = { gitea: "everything" }; });
|
||||
const business = loadBusiness("acme", { dir: f.dir, rolesDir: f.rolesDir });
|
||||
assert.deepEqual(business.launch.grantScope, { gitea: "everything" });
|
||||
assert.deepEqual(f.settings.grantScopeRead("acme"), { business: "acme", revision: f.revision(), scope: {}, fallback: true });
|
||||
});
|
||||
|
||||
test("row 298: the writer checks the scope, logs the change and removes the key for {}", () => {
|
||||
const f = fixture();
|
||||
assert.deepEqual(f.settings.grantScopeRead("acme").scope, {});
|
||||
const r = f.settings.grantScope("acme", { revision: f.revision(), scope: GITEA }, "jason");
|
||||
assert.deepEqual(r.scope, GITEA);
|
||||
assert.deepEqual(r.old, {});
|
||||
assert.ok(typeof r.transaction === "string" && r.transaction.length > 0);
|
||||
assert.deepEqual(f.onDisk().launch.grantScope, GITEA);
|
||||
assert.equal(f.log().at(-1).actor, "jason");
|
||||
const back = f.settings.grantScope("acme", { revision: f.revision(), scope: {} }, "jason");
|
||||
assert.deepEqual(back.old, GITEA);
|
||||
assert.equal(Object.hasOwn(f.onDisk().launch, "grantScope"), false);
|
||||
const bytes = readFileSync(f.file, "utf8");
|
||||
assert.throws(() => f.settings.grantScope("acme", { revision: f.revision(), scope: { gitea: {} } }, "jason"), e => e.code === "invalid-request");
|
||||
assert.throws(() => f.settings.grantScope("acme", { revision: f.revision(), scope: GITEA, extra: 1 }, "jason"), e => e.code === "invalid-request");
|
||||
assert.throws(() => f.settings.grantScope("acme", { revision: "0".repeat(64), scope: GITEA }, "jason"), e => e.code === "revision-conflict");
|
||||
assert.throws(() => f.settings.grantScope("acme", { revision: f.revision(), scope: {} }, "jason"), e => e.code === "organization-no-change");
|
||||
assert.equal(readFileSync(f.file, "utf8"), bytes);
|
||||
});
|
||||
|
||||
test("row 298 (sage-3594 Q1): the general writers can't write grantScope", () => {
|
||||
const f = fixture();
|
||||
const bytes = readFileSync(f.file, "utf8");
|
||||
assert.throws(() => f.limits.set("acme", { revision: f.revision(), grantScope: GITEA }, "jason"));
|
||||
assert.throws(() => f.limits.set("acme", { revision: f.revision(), grantScope: GITEA }, "chief-of-staff.chief"));
|
||||
assert.throws(() => f.settings.set("acme", { revision: f.revision(), instance: "coder", op: "change", grantScope: GITEA }, "jason"), e => e.code === "invalid-request");
|
||||
assert.throws(() => f.settings.veto("acme", { revision: f.revision(), op: "keep", name: "coder", grantScope: GITEA }, "jason"), e => e.code === "invalid-request");
|
||||
assert.equal(readFileSync(f.file, "utf8"), bytes);
|
||||
});
|
||||
@@ -1080,37 +1080,6 @@ reads the admin token on stdin, never argv; `admin rotate gitea --rotate-by DATE
|
||||
gitea --finish` and `admin remove gitea` keep the slot. run and admin are the human's alone. Nothing
|
||||
prints a token. A usage error exits 4 and a bus refusal 2.
|
||||
|
||||
`mosaic grants scope --business ID` reads the chief of staff's effective scope,
|
||||
its organization revision, and whether a bad value fell back to empty (Q228,
|
||||
row 298). `mosaic grants scope set --business ID --revision REV` reads scope JSON
|
||||
from stdin. This human-shell command is Approve for the scope change; it refuses
|
||||
agent callers through the same guard and human transport as `mosaic access`.
|
||||
Neither command accepts `--tester`. No Console page or chief-raised scope-change
|
||||
request exists. A stale revision refuses; malformed scope input writes nothing.
|
||||
|
||||
The setting is `launch.grantScope` in the business organization file. It defaults
|
||||
to `{}`, which covers nothing; a missing or invalid value has the same effect.
|
||||
Only Jason sets the live value. The supported shapes are:
|
||||
|
||||
```json
|
||||
{
|
||||
"gitea": { "agents": ["coder"], "projects": ["app"] },
|
||||
"host": { "agents": ["ops"], "hosts": ["web1"], "commands": [["systemctl", "status", "nginx"]] }
|
||||
}
|
||||
```
|
||||
|
||||
Either method may be omitted. All lists are distinct and nonempty. Every target
|
||||
project, agent, host and command must be listed for its method. Command arrays
|
||||
match token for token with equal lengths: `["ls"]` covers neither `["lsblk"]`
|
||||
nor `["ls", "-l"]`. Host enrollment always needs per-grant Approve.
|
||||
An in-scope request from the enrolled chief is closed and issued by the protected
|
||||
grant executor without an inbox approval; other requests wait for Approve.
|
||||
All grants retain their records and revoke path. Narrowing the scope before a
|
||||
grant is spent fails that grant with `scope-changed`; existing issued grants
|
||||
remain revocable. Setting `{}` removes the setting and restores per-grant Approve.
|
||||
Scope changes have organization receipts and a `grant.scope` event with the
|
||||
human proof and old/new digests; each automatic approval has `grant.scope.used`.
|
||||
|
||||
## Access (FED R5, #414)
|
||||
|
||||
```
|
||||
|
||||
@@ -2,20 +2,16 @@
|
||||
// starts the executor of a decision the human said yes to, and admin set, rotate and remove keep the
|
||||
// admin slot; those are the human's alone. status is the human's, the tester's or the chief's; revoke
|
||||
// and sweep the human's or, with --tester, the tester's. admin set reads the admin token on stdin, never
|
||||
// argv, and nothing here prints a token: the answers carry ids and names only. Row 298 (#1804, Q228):
|
||||
// scope reads the chief of staff's grant scope and scope set writes it from JSON on stdin; both are
|
||||
// the human's, and the set is the human's Approve of the change.
|
||||
// argv, and nothing here prints a token: the answers carry ids and names only.
|
||||
import { CliError } from "./errors.mjs";
|
||||
|
||||
export const GRANTS_USAGE =
|
||||
"mosaic grants status gitea [--json] [--tester] [--business ID] | run DECISION [--business ID] | " +
|
||||
"revoke AGENT gitea --reason R [--tester] [--business ID] | sweep gitea [--tester] [--business ID] | " +
|
||||
"admin set gitea --url URL --account NAME --rotate-by DATE [--business ID] | " +
|
||||
"admin rotate gitea --rotate-by DATE | admin rotate gitea --finish | admin remove gitea | " +
|
||||
"scope [--business ID] | scope set --revision REV [--business ID] (the scope JSON on stdin)";
|
||||
"admin rotate gitea --rotate-by DATE | admin rotate gitea --finish | admin remove gitea";
|
||||
const MAX_TOKEN = 16384;
|
||||
const MAX_SCOPE = 65536;
|
||||
const VALUES = ["--business", "--reason", "--url", "--account", "--rotate-by", "--revision"];
|
||||
const VALUES = ["--business", "--reason", "--url", "--account", "--rotate-by"];
|
||||
|
||||
function parse(args) {
|
||||
const out = { positional: [], flags: new Set(), values: {} };
|
||||
@@ -44,21 +40,6 @@ async function readToken(stdin) {
|
||||
return text;
|
||||
}
|
||||
|
||||
// Row 298: the whole of stdin, one JSON scope. {} is the empty scope.
|
||||
async function readScope(stdin) {
|
||||
let text = "";
|
||||
for await (const chunk of stdin) {
|
||||
text += chunk;
|
||||
if (text.length > MAX_SCOPE) throw new CliError("refused: the scope on stdin is too long", 2);
|
||||
}
|
||||
if (!text.trim()) throw new CliError("refused: no scope on stdin; pipe the scope JSON, {} for none", 4);
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
throw new CliError("refused: the scope on stdin is not JSON", 4);
|
||||
}
|
||||
}
|
||||
|
||||
// Each shape: the positional count after the verb, the flags and values it takes and needs.
|
||||
const SHAPES = {
|
||||
status: { n: 1, flags: ["--json", "--tester"], need: [], may: ["--business"] },
|
||||
@@ -68,8 +49,6 @@ const SHAPES = {
|
||||
"admin set": { n: 1, flags: [], need: ["--url", "--account", "--rotate-by"], may: ["--business"] },
|
||||
"admin rotate": { n: 1, flags: ["--finish"], need: [], may: ["--rotate-by", "--business"] },
|
||||
"admin remove": { n: 1, flags: [], need: [], may: ["--business"] },
|
||||
scope: { n: 0, flags: [], need: [], may: ["--business"] },
|
||||
"scope set": { n: 0, flags: [], need: ["--revision"], may: ["--business"] },
|
||||
};
|
||||
|
||||
const lines = (s) => {
|
||||
@@ -92,9 +71,6 @@ export async function grantsCommand(args, io, callFor) {
|
||||
if (verb === "admin") {
|
||||
verb = `admin ${rest[0] ?? ""}`;
|
||||
rest = rest.slice(1);
|
||||
} else if (verb === "scope" && rest[0] === "set") {
|
||||
verb = "scope set";
|
||||
rest = rest.slice(1);
|
||||
}
|
||||
// Own keys only, so `constructor` is a usage error, not a verb.
|
||||
const shape = Object.hasOwn(SHAPES, verb ?? "") ? SHAPES[verb] : null;
|
||||
@@ -103,7 +79,7 @@ export async function grantsCommand(args, io, callFor) {
|
||||
[...parsed.flags].some((f) => !shape.flags.includes(f)) ||
|
||||
Object.keys(parsed.values).some((v) => !shape.need.includes(v) && !shape.may.includes(v)) ||
|
||||
shape.need.some((v) => !Object.hasOwn(parsed.values, v)) ||
|
||||
(verb !== "run" && verb !== "scope" && verb !== "scope set" && rest.at(-1) !== "gitea") ||
|
||||
(verb !== "run" && rest.at(-1) !== "gitea") ||
|
||||
(verb === "admin rotate" && parsed.flags.has("--finish") === Object.hasOwn(parsed.values, "--rotate-by"))
|
||||
)
|
||||
throw new CliError(GRANTS_USAGE, 4);
|
||||
@@ -117,8 +93,6 @@ export async function grantsCommand(args, io, callFor) {
|
||||
else if (verb === "sweep") request = ["grants.sweep", { service: "gitea" }];
|
||||
else if (verb === "admin set")
|
||||
request = ["grants.admin.set", { service: "gitea", url: v["--url"], account: v["--account"], rotateBy: v["--rotate-by"], token: await readToken(io.stdin) }];
|
||||
else if (verb === "scope") request = ["grants.scope", {}];
|
||||
else if (verb === "scope set") request = ["grants.scope.set", { revision: v["--revision"], scope: await readScope(io.stdin) }];
|
||||
else if (verb === "admin rotate")
|
||||
request = ["grants.admin.rotate", parsed.flags.has("--finish") ? { service: "gitea", finish: true } : { service: "gitea", rotateBy: v["--rotate-by"] }];
|
||||
else request = ["grants.admin.remove", { service: "gitea" }];
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
// Row 298/Q228: the real CLI parser and human-shell refusal. Transport is a
|
||||
// recorder; no socket, config, credential or live grant is accessed.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { main } from "../src/cli.mjs";
|
||||
import { humanEnv, io } from "./helpers.mjs";
|
||||
|
||||
const AGENT_MARKERS = ["MOSAIC_BUS_CAP", "MOSAIC_RUN_ID", "MOSAIC_AGENT_RUN", "CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "CODEX_THREAD_ID", "PI_AGENT_DIR"];
|
||||
const REVISION = "a".repeat(64);
|
||||
const SCOPE = { gitea: { agents: ["coder"], projects: ["app"] } };
|
||||
const READ = ["grants", "scope", "--business", "acme"];
|
||||
const SET = ["grants", "scope", "set", "--business", "acme", "--revision", REVISION];
|
||||
function setup(env = humanEnv(), stdin = JSON.stringify(SCOPE)) {
|
||||
const x = io(env);
|
||||
x.stdin.end(stdin);
|
||||
const calls = [];
|
||||
const deps = {
|
||||
system: { dataRoot: "/nonexistent" },
|
||||
transport: (business) => async (verb, args) => {
|
||||
calls.push({ business, verb, args });
|
||||
return { revision: REVISION, scope: SCOPE, fallback: false };
|
||||
},
|
||||
testerTransport: () => async () => { throw new Error("tester transport must never run"); },
|
||||
};
|
||||
return { x, calls, deps };
|
||||
}
|
||||
|
||||
// Break caught: dropping scope dispatch or misrouting the writer's revision.
|
||||
test("row 298 CLI: scope reads and scope set sends JSON and the observed revision", async () => {
|
||||
const f = setup();
|
||||
await main(READ, f.x, f.deps);
|
||||
assert.deepEqual(f.calls, [{ business: "acme", verb: "grants.scope", args: {} }]);
|
||||
assert.deepEqual(JSON.parse(f.x.out.text), { revision: REVISION, scope: SCOPE, fallback: false });
|
||||
const w = setup();
|
||||
await main(SET, w.x, w.deps);
|
||||
assert.deepEqual(w.calls, [{ business: "acme", verb: "grants.scope.set", args: { revision: REVISION, scope: SCOPE } }]);
|
||||
const empty = setup(humanEnv(), "{}");
|
||||
await main(SET, empty.x, empty.deps);
|
||||
assert.deepEqual(empty.calls[0].args.scope, {});
|
||||
});
|
||||
|
||||
// Break caught: a new grants path bypassing access's refuseInsideAgent guard.
|
||||
test("row 298 CLI: every access agent marker refuses scope set before transport", async () => {
|
||||
for (const marker of AGENT_MARKERS) {
|
||||
const f = setup({ ...humanEnv(), [marker]: "agent" });
|
||||
await assert.rejects(main(SET, f.x, f.deps), (e) => e.exitCode === 3 && /human shell/.test(e.message), marker);
|
||||
assert.deepEqual(f.calls, []);
|
||||
assert.equal(f.x.out.text, "");
|
||||
}
|
||||
});
|
||||
|
||||
// Break caught: --tester selecting a nonhuman transport for this approval.
|
||||
test("row 298 CLI: --tester cannot read or change the scope", async () => {
|
||||
for (const args of [READ, SET]) {
|
||||
const f = setup();
|
||||
await assert.rejects(main([...args, "--tester"], f.x, f.deps), (e) => e.exitCode === 4);
|
||||
assert.deepEqual(f.calls, []);
|
||||
}
|
||||
});
|
||||
|
||||
// Break caught: a missing revision or invalid stdin being sent as an approval.
|
||||
test("row 298 CLI: missing revision and empty, invalid or oversized JSON refuse without a call", async () => {
|
||||
for (const [args, input, code] of [
|
||||
[SET.slice(0, -2), "{}", 4],
|
||||
[SET, "", 4],
|
||||
[SET, " \n", 4],
|
||||
[SET, "{bad", 4],
|
||||
[SET, " ".repeat(65537), 2],
|
||||
]) {
|
||||
const f = setup(humanEnv(), input);
|
||||
await assert.rejects(main(args, f.x, f.deps), (e) => e.exitCode === code);
|
||||
assert.deepEqual(f.calls, []);
|
||||
}
|
||||
});
|
||||
+17
-10
@@ -104,21 +104,28 @@ in an extension other than `.md`, in any case, embeds an attachment, and it is n
|
||||
a dot and up to 8 letters and digits, at least one a letter. Any other unresolved embed is R4, and so is
|
||||
an unresolved front matter value written as an embed (C2-01b N3, sage-1900).
|
||||
|
||||
## renderItems
|
||||
## renderReply
|
||||
|
||||
`renderItems(body, { scopeOf, changed })` turns a reply body into the plain text both channels show.
|
||||
It is pure. Item kinds are `fact`, `finding`, `correction` (with `from` and `to`), `question` and
|
||||
`idea`. Each cite is a path with commit, line range and quote, or a url with a quote.
|
||||
`renderReply(body, { check, changed, name })` turns a stored reply into what both channels show. It
|
||||
replaces C2-01's items renderer and is pure. A body is free text, or the design 5.2 object
|
||||
`{ "text", "citations" }` with library cites `{ path, commit, lines, quote, as? }` and web cites
|
||||
`{ url, quote }`. Any other body, a JSON object with another key or a bad cite included, renders as
|
||||
free text: the whole body, with no citations.
|
||||
|
||||
`check` is the stored check result (C2-06), `{ citations: [{ scope, agent }] }` in body order. A path
|
||||
cite's badge (`canon`, `playground` or `supporting`) and its agent-written label come only from it. A
|
||||
url cite is always `web`, and is a link only when its scheme is `https`. `changed({ path, commit }, i)`
|
||||
says whether the file's last commit is newer than the cited one. `plain` is the rendering as text:
|
||||
|
||||
```
|
||||
1. Correction: Chapter 1 says grey.
|
||||
From: "green eyes"
|
||||
To: "grey eyes"
|
||||
[canon] Book One/Characters/Mara Vell.md:9 @bbbbbbb "green eyes" (changed since)
|
||||
Mara's eyes are grey in chapter 3.
|
||||
[canon] Book One/Chapters/Chapter 03.md:41 @aaaaaaa "her grey eyes"
|
||||
[supporting] Notes/Eyes.md:9-10 @aaaaaaa "green eyes" (agent-written: acme/scribe) (changed since)
|
||||
[web] https://example.invalid/tides "spring tides"
|
||||
```
|
||||
|
||||
A body of `{ "items": [], "notice": ... }` gives `No cited answer`, `No answer` or `The turn failed`.
|
||||
Any other shape gives the fixed line `This reply could not be shown.` and none of the body's text.
|
||||
The broker's write notice, `{ "notice": "library-write", op, path, commit }`, gives the line
|
||||
`<name> wrote <path> @<commit>` or `<name> deleted <path> @<commit>`.
|
||||
|
||||
## Survey
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ export { checkMapping, TYPES } from './mapping.mjs';
|
||||
export { classify } from './scope.mjs';
|
||||
export { extractLinks, parseWikilink, resolver, formOf, isAttachment } from './links.mjs';
|
||||
export { readLibrary, listTree, readTexts, parseFile, parseFrontMatter, headCommit, MAX_BLOB, LINK_FIELDS } from './reader.mjs';
|
||||
export { renderItems, KINDS, NOTICES, UNREADABLE } from './render.mjs';
|
||||
export { renderReply, SCOPES, NOTICE_OPS } 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';
|
||||
@@ -1,59 +1,72 @@
|
||||
// renderItems(body): how a stored cited reply looks (design 7.4), shared by the Console chat (C2-10b)
|
||||
// and the Discord relay (C2-18). Pure: no git, no clock. The caller passes what it knows about cites.
|
||||
// The guard (C2-06) checks limits before a reply is stored. This only refuses to show a body that
|
||||
// doesn't have the reply shape, and then it shows a fixed line, never the body's text.
|
||||
export const KINDS = Object.freeze({ fact: 'Fact', finding: 'Finding', correction: 'Correction', question: 'Question', idea: 'Idea' });
|
||||
export const NOTICES = Object.freeze({ 'no-cited-answer': 'No cited answer', 'no-answer': 'No answer', 'turn-failed': 'The turn failed' });
|
||||
export const UNREADABLE = 'This reply could not be shown.';
|
||||
// renderReply(body): how a stored reply looks (design 7.4), shared by the Console chat (C2-10b) and the
|
||||
// Discord relay (C2-18). It replaces C2-01's items renderer. Pure: no git, no clock, no DOM. The caller
|
||||
// passes what only the bus knows: the stored check result (C2-06) and whether a cited file changed.
|
||||
// The guard checks a reply before it is stored. This only decides how a body reads; it trusts nothing
|
||||
// in the body for a scope or the agent-written label.
|
||||
export const SCOPES = Object.freeze(['canon', 'playground', 'supporting', 'web']);
|
||||
export const NOTICE_OPS = Object.freeze({ write: 'wrote', delete: 'deleted' });
|
||||
const SHA = /^[0-9a-f]{40}$/;
|
||||
|
||||
const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
const line = n => Number.isSafeInteger(n) && n > 0;
|
||||
function cite(value, { scopeOf, changed }) {
|
||||
if (!plain(value) || typeof value.quote !== 'string') return null;
|
||||
if (Object.hasOwn(value, 'url')) {
|
||||
if (typeof value.url !== 'string' || Object.hasOwn(value, 'path')) return null;
|
||||
return { badge: 'web', url: value.url, ref: value.url, quote: value.quote, changed: false };
|
||||
}
|
||||
const { path, commit, lines } = value;
|
||||
if (typeof path !== 'string' || !SHA.test(commit ?? '') || !Array.isArray(lines) || lines.length !== 2
|
||||
|| !line(lines[0]) || !line(lines[1]) || lines[1] < lines[0]) return null;
|
||||
const badge = scopeOf?.(path);
|
||||
const ref = `${path}:${lines[0] === lines[1] ? lines[0] : `${lines[0]}-${lines[1]}`} @${commit.slice(0, 7)}`;
|
||||
return { badge: badge === 'canon' || badge === 'playground' ? badge : null, path, commit, lines: [...lines], ref,
|
||||
quote: value.quote, changed: changed?.({ path, commit }) === true };
|
||||
}
|
||||
const only = (value, keys) => Object.keys(value).every(k => keys.includes(k));
|
||||
const quoted = text => JSON.stringify(text);
|
||||
// webby-0041 N5: only an https url is ever a link. Anything else, javascript: and http: included, is text.
|
||||
function https(url) {
|
||||
try { return new URL(url).protocol === 'https:' ? url : null; } catch { return null; }
|
||||
}
|
||||
|
||||
// body: the stored message body (a JSON string or the parsed object).
|
||||
// options.scopeOf(path) -> 'canon' | 'playground' | undefined, for the scope badge.
|
||||
// options.changed({path, commit}) -> true when the file's last commit is newer than the cited one.
|
||||
// Returns { kind: 'items', items, text } | { kind: 'notice', notice, text } | { kind: 'unreadable', text }.
|
||||
export function renderItems(body, options = {}) {
|
||||
const unreadable = { kind: 'unreadable', text: UNREADABLE };
|
||||
// The design 5.2 cite shapes. null when the value isn't one, and then the body is not a cited reply.
|
||||
function cite(value) {
|
||||
if (!plain(value) || typeof value.quote !== 'string') return null;
|
||||
if (Object.hasOwn(value, 'url')) return typeof value.url === 'string' && only(value, ['url', 'quote']) ? { url: value.url, quote: value.quote } : null;
|
||||
const { path, commit, lines } = value;
|
||||
if (!only(value, ['path', 'commit', 'lines', 'quote', 'as']) || typeof path !== 'string' || !SHA.test(commit ?? '')
|
||||
|| !Array.isArray(lines) || lines.length !== 2 || !line(lines[0]) || !line(lines[1]) || lines[1] < lines[0]
|
||||
|| (Object.hasOwn(value, 'as') && value.as !== 'canon')) return null;
|
||||
return { path, commit, lines: [...lines], quote: value.quote, canon: value.as === 'canon' };
|
||||
}
|
||||
|
||||
// check: the stored entry for this cite, { scope, agent }, or undefined. A path cite takes its badge only
|
||||
// from it; a url cite is web by its shape. agent is the Mosaic-Agent trailer value, never the body's.
|
||||
function shown(c, check, changed, i) {
|
||||
if (c.url !== undefined) return { badge: 'web', url: c.url, link: https(c.url), ref: c.url, quote: c.quote, agent: null, changed: false };
|
||||
const scope = plain(check) && SCOPES.includes(check.scope) && check.scope !== 'web' ? check.scope : null;
|
||||
const agent = plain(check) && typeof check.agent === 'string' && check.agent !== '' ? check.agent : null;
|
||||
const ref = `${c.path}:${c.lines[0] === c.lines[1] ? c.lines[0] : `${c.lines[0]}-${c.lines[1]}`} @${c.commit.slice(0, 7)}`;
|
||||
return { badge: scope, path: c.path, commit: c.commit, lines: c.lines, ref, quote: c.quote, canon: c.canon, agent,
|
||||
changed: changed?.({ path: c.path, commit: c.commit }, i) === true };
|
||||
}
|
||||
|
||||
// The broker's write notice (design, writes): {"notice": "library-write", op, path, commit}, no agent text.
|
||||
function notice(value, name) {
|
||||
if (!plain(value) || value.notice !== 'library-write' || !only(value, ['notice', 'op', 'path', 'commit'])
|
||||
|| !Object.hasOwn(NOTICE_OPS, value.op) || typeof value.path !== 'string' || !SHA.test(value.commit ?? '')) return null;
|
||||
const who = typeof name === 'string' && name !== '' ? name : 'An agent';
|
||||
return { kind: 'notice', op: value.op, path: value.path, commit: value.commit,
|
||||
text: `${who} ${NOTICE_OPS[value.op]} ${value.path} @${value.commit.slice(0, 7)}` };
|
||||
}
|
||||
|
||||
// body: the stored message body, a string (or an already parsed object).
|
||||
// options.check: the stored check result, { citations: [{ scope, agent }] } in body order, or null.
|
||||
// options.changed({path, commit}, index) -> true when the file's last commit is newer than the cited one.
|
||||
// options.name: the sender's name for a notice line.
|
||||
// Returns { kind: 'text', text, plain } | { kind: 'reply', text, citations, plain } | { kind: 'notice', op, path, commit, text, plain }.
|
||||
// plain is the whole rendering as text, for a channel that shows no markup.
|
||||
export function renderReply(body, options = {}) {
|
||||
const raw = typeof body === 'string' ? body : JSON.stringify(body) ?? String(body);
|
||||
let value = body;
|
||||
if (typeof body === 'string') { try { value = JSON.parse(body); } catch { return unreadable; } }
|
||||
if (!plain(value) || !Array.isArray(value.items)) return unreadable;
|
||||
const keys = Object.keys(value);
|
||||
if (value.items.length === 0) {
|
||||
if (keys.length !== 2 || !Object.hasOwn(NOTICES, value.notice)) return unreadable;
|
||||
return { kind: 'notice', notice: value.notice, text: NOTICES[value.notice] };
|
||||
}
|
||||
if (keys.length !== 1) return unreadable;
|
||||
const items = [];
|
||||
for (const item of value.items) {
|
||||
if (!plain(item) || !Object.hasOwn(KINDS, item.kind) || typeof item.text !== 'string' || !Array.isArray(item.cites)) return unreadable;
|
||||
const correction = item.kind === 'correction';
|
||||
if (correction && (typeof item.from !== 'string' || typeof item.to !== 'string')) return unreadable;
|
||||
const cites = item.cites.map(c => cite(c, options));
|
||||
if (cites.some(c => c === null)) return unreadable;
|
||||
items.push({ kind: item.kind, label: KINDS[item.kind], text: item.text,
|
||||
...(correction ? { from: item.from, to: item.to } : {}), cites });
|
||||
}
|
||||
const text = items.map((item, i) => [
|
||||
`${i + 1}. ${item.label}: ${item.text}`,
|
||||
...(item.kind === 'correction' ? [` From: ${quoted(item.from)}`, ` To: ${quoted(item.to)}`] : []),
|
||||
...item.cites.map(c => ` ${c.badge ? `[${c.badge}] ` : ''}${c.ref} ${quoted(c.quote)}${c.changed ? ' (changed since)' : ''}`),
|
||||
].join('\n')).join('\n');
|
||||
return { kind: 'items', items, text };
|
||||
if (typeof body === 'string') { try { value = JSON.parse(body); } catch { value = undefined; } }
|
||||
const n = notice(value, options.name);
|
||||
if (n) return { ...n, plain: n.text };
|
||||
// Design 5.2: a JSON object must be {text, citations} and nothing else. Any other body is a reply whose
|
||||
// text is the whole body and which carries no citation.
|
||||
const free = { kind: 'text', text: raw, plain: raw };
|
||||
if (!plain(value) || typeof value.text !== 'string' || !Array.isArray(value.citations) || !only(value, ['text', 'citations'])) return free;
|
||||
const cites = value.citations.map(cite);
|
||||
if (cites.some(c => c === null)) return free;
|
||||
const checks = plain(options.check) && Array.isArray(options.check.citations) ? options.check.citations : [];
|
||||
const citations = cites.map((c, i) => shown(c, checks[i], options.changed, i));
|
||||
const lines = citations.map(c => ` ${c.badge ? `[${c.badge}] ` : ''}${c.ref} ${quoted(c.quote)}${c.agent ? ` (agent-written: ${c.agent})` : ''}${c.changed ? ' (changed since)' : ''}`);
|
||||
return { kind: 'reply', text: value.text, citations, plain: [value.text, ...lines].join('\n') };
|
||||
}
|
||||
@@ -1,80 +1,94 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { renderItems, UNREADABLE } from '../src/index.mjs';
|
||||
import { renderReply } from '../src/index.mjs';
|
||||
|
||||
// Row 225 (C2-10b): renderReply replaces renderItems (design 7.4, the reply schema of 5.2).
|
||||
const A = 'a'.repeat(40), B = 'b'.repeat(40);
|
||||
const cite = (path, lines, quote, commit = A) => ({ path, commit, lines, quote });
|
||||
const options = {
|
||||
scopeOf: path => path.includes('/Playground/') ? 'playground' : path.endsWith('.md') ? 'canon' : undefined,
|
||||
changed: ({ path, commit }) => path === 'Book One/Characters/Mara Vell.md' && commit === A,
|
||||
};
|
||||
const render = items => renderItems(JSON.stringify({ items }), options);
|
||||
const cite = (path, lines, quote, commit = A, extra = {}) => ({ path, commit, lines, quote, ...extra });
|
||||
const web = (url, quote = 'spring tides') => ({ url, quote });
|
||||
const reply = (text, citations) => JSON.stringify({ text, citations });
|
||||
const CITED = reply("Mara's eyes are grey in chapter 3.", [
|
||||
cite('Book One/Chapters/Chapter 03.md', [41, 41], 'her grey eyes', A, { as: 'canon' }),
|
||||
cite('Book One/Playground/What If.md', [5, 6], 'a storm', B),
|
||||
cite('Notes/Eyes.md', [9, 10], 'green eyes'),
|
||||
web('https://example.invalid/tides'),
|
||||
]);
|
||||
const CHECK = { citations: [{ scope: 'canon', agent: null }, { scope: 'playground', agent: null },
|
||||
{ scope: 'supporting', agent: 'acme/scribe' }, { scope: 'web', agent: null }] };
|
||||
const changed = ({ path, commit }) => path === 'Notes/Eyes.md' && commit === A;
|
||||
|
||||
// One snapshot per item kind: the plain text both channels show.
|
||||
const SNAPSHOTS = {
|
||||
fact: [{ kind: 'fact', text: 'Tomas is the ferryman.', cites: [cite('Book One/Characters/Tomas.md', [3, 3], 'the ferryman', B)] }],
|
||||
finding: [{ kind: 'finding', text: 'Eye colour differs.', cites: [cite('Book One/Chapters/Chapter 01.md', [41, 41], 'her grey eyes'),
|
||||
cite('Book One/Characters/Mara Vell.md', [9, 10], 'green eyes')] }],
|
||||
correction: [{ kind: 'correction', text: 'Chapter 1 says grey.', from: 'green eyes', to: 'grey eyes',
|
||||
cites: [cite('Book One/Characters/Mara Vell.md', [9, 9], 'green eyes', B)] }],
|
||||
question: [{ kind: 'question', text: 'Is the harbor north of the town?', cites: [cite('Book One/Places/Harbor Town.md', [2, 4], 'the harbor', B)] }],
|
||||
idea: [{ kind: 'idea', text: 'A storm could close the harbor.', cites: [cite('Book One/Playground/What If.md', [5, 5], 'a storm', B),
|
||||
{ url: 'https://example.invalid/tides', quote: 'spring tides' }] }],
|
||||
};
|
||||
const EXPECTED = {
|
||||
fact: '1. Fact: Tomas is the ferryman.\n [canon] Book One/Characters/Tomas.md:3 @bbbbbbb "the ferryman"',
|
||||
finding: '1. Finding: Eye colour differs.\n [canon] Book One/Chapters/Chapter 01.md:41 @aaaaaaa "her grey eyes"\n'
|
||||
+ ' [canon] Book One/Characters/Mara Vell.md:9-10 @aaaaaaa "green eyes" (changed since)',
|
||||
correction: '1. Correction: Chapter 1 says grey.\n From: "green eyes"\n To: "grey eyes"\n'
|
||||
+ ' [canon] Book One/Characters/Mara Vell.md:9 @bbbbbbb "green eyes"',
|
||||
question: '1. Question: Is the harbor north of the town?\n [canon] Book One/Places/Harbor Town.md:2-4 @bbbbbbb "the harbor"',
|
||||
idea: '1. Idea: A storm could close the harbor.\n [playground] Book One/Playground/What If.md:5 @bbbbbbb "a storm"\n'
|
||||
+ ' [web] https://example.invalid/tides "spring tides"',
|
||||
};
|
||||
|
||||
for (const kind of Object.keys(SNAPSHOTS)) {
|
||||
test(`renderItems snapshot: ${kind}`, () => {
|
||||
const out = render(SNAPSHOTS[kind]);
|
||||
assert.equal(out.kind, 'items');
|
||||
assert.equal(out.text, EXPECTED[kind]);
|
||||
assert.equal(out.items[0].kind, kind);
|
||||
});
|
||||
}
|
||||
|
||||
test('renderItems numbers several items and takes a parsed body as well as a string', () => {
|
||||
const items = [...SNAPSHOTS.fact, ...SNAPSHOTS.question];
|
||||
const out = renderItems({ items }, options);
|
||||
assert.equal(out.text, `${EXPECTED.fact}\n${EXPECTED.question.replace(/^1\./, '2.')}`);
|
||||
assert.deepEqual(out.items[1].cites[0], { badge: 'canon', path: 'Book One/Places/Harbor Town.md', commit: B, lines: [2, 4],
|
||||
ref: 'Book One/Places/Harbor Town.md:2-4 @bbbbbbb', quote: 'the harbor', changed: false });
|
||||
test('free text renders as itself with no citations', () => {
|
||||
for (const body of ['Grey, in chapter 3.', '', '"a JSON string"', '[1, 2]', '42', 'null'])
|
||||
assert.deepEqual(renderReply(body, { check: CHECK }), { kind: 'text', text: body, plain: body });
|
||||
});
|
||||
|
||||
test('without options a path cite has no badge and is never marked changed', () => {
|
||||
assert.equal(renderItems({ items: SNAPSHOTS.finding }).text,
|
||||
'1. Finding: Eye colour differs.\n Book One/Chapters/Chapter 01.md:41 @aaaaaaa "her grey eyes"\n'
|
||||
+ ' Book One/Characters/Mara Vell.md:9-10 @aaaaaaa "green eyes"');
|
||||
test('a cited reply renders its text, then each cite with the badge from the check result', () => {
|
||||
const out = renderReply(CITED, { check: CHECK, changed });
|
||||
assert.equal(out.kind, 'reply');
|
||||
assert.equal(out.text, "Mara's eyes are grey in chapter 3.");
|
||||
assert.deepEqual(out.citations.map(c => c.badge), ['canon', 'playground', 'supporting', 'web']);
|
||||
assert.equal(out.plain, "Mara's eyes are grey in chapter 3.\n"
|
||||
+ ' [canon] Book One/Chapters/Chapter 03.md:41 @aaaaaaa "her grey eyes"\n'
|
||||
+ ' [playground] Book One/Playground/What If.md:5-6 @bbbbbbb "a storm"\n'
|
||||
+ ' [supporting] Notes/Eyes.md:9-10 @aaaaaaa "green eyes" (agent-written: acme/scribe) (changed since)\n'
|
||||
+ ' [web] https://example.invalid/tides "spring tides"');
|
||||
assert.deepEqual(out.citations[0], { badge: 'canon', path: 'Book One/Chapters/Chapter 03.md', commit: A, lines: [41, 41],
|
||||
ref: 'Book One/Chapters/Chapter 03.md:41 @aaaaaaa', quote: 'her grey eyes', canon: true, agent: null, changed: false });
|
||||
assert.deepEqual(out.citations[3], { badge: 'web', url: 'https://example.invalid/tides', link: 'https://example.invalid/tides',
|
||||
ref: 'https://example.invalid/tides', quote: 'spring tides', agent: null, changed: false });
|
||||
});
|
||||
|
||||
const NOTICE_SNAPSHOTS = { 'no-cited-answer': 'No cited answer', 'no-answer': 'No answer', 'turn-failed': 'The turn failed' };
|
||||
for (const [notice, text] of Object.entries(NOTICE_SNAPSHOTS)) {
|
||||
test(`renderItems snapshot: notice ${notice}`, () => {
|
||||
assert.deepEqual(renderItems(JSON.stringify({ items: [], notice }), options), { kind: 'notice', notice, text });
|
||||
});
|
||||
}
|
||||
test('a parsed body renders as its string does', () => {
|
||||
assert.deepEqual(renderReply(JSON.parse(CITED), { check: CHECK, changed }), renderReply(CITED, { check: CHECK, changed }));
|
||||
});
|
||||
|
||||
test('a body without the reply shape shows a fixed line and none of its text', () => {
|
||||
const secret = 'free prose the guard would refuse';
|
||||
const bodies = [secret, JSON.stringify(secret), '{"items": [], "notice": "other"}', JSON.stringify({ items: [], notice: 'no-answer', extra: secret }),
|
||||
JSON.stringify({ items: [] }), JSON.stringify({ items: [{ kind: 'story', text: secret, cites: [] }] }),
|
||||
JSON.stringify({ items: [{ kind: 'fact', text: secret }] }), JSON.stringify({ items: SNAPSHOTS.fact, prose: secret }),
|
||||
JSON.stringify({ items: [{ kind: 'correction', text: secret, cites: [] }] }),
|
||||
JSON.stringify({ items: [{ kind: 'fact', text: secret, cites: [cite('a.md', [2, 1], 'q')] }] }),
|
||||
JSON.stringify({ items: [{ kind: 'fact', text: secret, cites: [{ path: 'a.md', commit: 'abc', lines: [1, 1], quote: 'q' }] }] }),
|
||||
JSON.stringify({ items: [{ kind: 'fact', text: secret, cites: [{ url: 'https://x.invalid', path: 'a.md', quote: 'q' }] }] }),
|
||||
null, 42, '[]'];
|
||||
for (const body of bodies) {
|
||||
const out = renderItems(body, options);
|
||||
assert.deepEqual(out, { kind: 'unreadable', text: UNREADABLE }, String(body));
|
||||
assert.ok(!JSON.stringify(out).includes(secret));
|
||||
test('without a check result a path cite has no badge, no label and is never changed', () => {
|
||||
const out = renderReply(CITED);
|
||||
assert.deepEqual(out.citations.map(c => [c.badge, c.agent, c.changed]),
|
||||
[[null, null, false], [null, null, false], [null, null, false], ['web', null, false]]);
|
||||
});
|
||||
|
||||
test('the label and the badge come from the check result only, never from a bad entry', () => {
|
||||
const bad = { citations: [{ scope: 'web', agent: '' }, { scope: 'story', agent: 7 }, null, { scope: 'canon', agent: 'x/y' }] };
|
||||
const out = renderReply(CITED, { check: bad });
|
||||
// A path cite can't be web; a url cite is web by its shape whatever the check says, and never agent-written.
|
||||
assert.deepEqual(out.citations.map(c => [c.badge, c.agent]), [[null, null], [null, null], [null, null], ['web', null]]);
|
||||
});
|
||||
|
||||
test('a web cite is a link only for https', () => {
|
||||
const urls = { 'https://example.invalid/a': 'https://example.invalid/a', 'http://example.invalid/a': null,
|
||||
'javascript:alert(1)': null, 'JAVASCRIPT:alert(1)': null, 'data:text/html,x': null, 'not a url': null, '//example.invalid': null };
|
||||
for (const [url, link] of Object.entries(urls)) {
|
||||
const out = renderReply(reply('t', [web(url)]));
|
||||
assert.equal(out.kind, 'reply', url);
|
||||
assert.equal(out.citations[0].link, link, url);
|
||||
assert.equal(out.citations[0].ref, url);
|
||||
}
|
||||
});
|
||||
|
||||
test('a write notice renders its fixed line', () => {
|
||||
const body = op => JSON.stringify({ notice: 'library-write', op, path: 'Notes/Eyes.md', commit: B });
|
||||
assert.deepEqual(renderReply(body('write'), { name: 'Scribe' }),
|
||||
{ kind: 'notice', op: 'write', path: 'Notes/Eyes.md', commit: B, text: 'Scribe wrote Notes/Eyes.md @bbbbbbb', plain: 'Scribe wrote Notes/Eyes.md @bbbbbbb' });
|
||||
assert.equal(renderReply(body('delete'), { name: 'Scribe' }).text, 'Scribe deleted Notes/Eyes.md @bbbbbbb');
|
||||
assert.equal(renderReply(body('write')).text, 'An agent wrote Notes/Eyes.md @bbbbbbb');
|
||||
});
|
||||
|
||||
test('a body without the reply or notice shape is free text: the whole body, no citations', () => {
|
||||
const bodies = [
|
||||
JSON.stringify({ text: 't' }), JSON.stringify({ citations: [] }), JSON.stringify({ text: 1, citations: [] }),
|
||||
JSON.stringify({ text: 't', citations: [], extra: 1 }), JSON.stringify({ items: [] }),
|
||||
reply('t', [cite('a.md', [2, 1], 'q')]), reply('t', [cite('a.md', [0, 1], 'q')]), reply('t', [cite('a.md', [1], 'q')]),
|
||||
reply('t', [{ path: 'a.md', commit: 'abc', lines: [1, 1], quote: 'q' }]), reply('t', [cite('a.md', [1, 1], 'q', A, { as: 'playground' })]),
|
||||
reply('t', [cite('a.md', [1, 1], 'q', A, { scope: 'canon' })]), reply('t', [{ url: 'https://x.invalid', path: 'a.md', quote: 'q' }]),
|
||||
reply('t', [{ url: 'https://x.invalid' }]), reply('t', [{ url: 'https://x.invalid', quote: 'q', agent: 'x/y' }]),
|
||||
JSON.stringify({ notice: 'library-write', op: 'move', path: 'a.md', commit: A }),
|
||||
JSON.stringify({ notice: 'library-write', op: 'write', path: 'a.md', commit: 'abc' }),
|
||||
JSON.stringify({ notice: 'library-write', op: 'write', path: 'a.md', commit: A, by: 'x' }),
|
||||
];
|
||||
for (const body of bodies) assert.deepEqual(renderReply(body, { check: CHECK }), { kind: 'text', text: body, plain: body }, body);
|
||||
});
|
||||
|
||||
test('an empty citations list is a reply with no cites', () => {
|
||||
assert.deepEqual(renderReply(reply('Grey.', [])), { kind: 'reply', text: 'Grey.', citations: [], plain: 'Grey.' });
|
||||
});
|
||||
@@ -189,5 +189,7 @@ const STATUS = {
|
||||
// leaves the remote unreachable, like no bus to ask.
|
||||
'library-sync-busy': 409, 'library-diverged': 409, 'library-accept-stale': 409, 'library-accept-invalid': 400,
|
||||
'library-credential-missing': 503, 'library-fetch-failed': 503,
|
||||
// Row 225 (C2-10b): a Revert of a commit that isn't an agent's is hers to read, like stale-base.
|
||||
'library-revert-not-agent': 409,
|
||||
};
|
||||
export const busStatus = code => STATUS[code] ?? 502;
|
||||
@@ -68,14 +68,14 @@ 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, libraryRevert = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, decisionWrite = null, busLine, system = null;
|
||||
let processing = null, ownerCall = null;
|
||||
try {
|
||||
system = loadSystem();
|
||||
const { dataRoot } = system;
|
||||
const call = ownerCall = busCaller({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
|
||||
// FED R6 (#1800): the bus-backed options, shared with each granted user's session (console-bus.mjs).
|
||||
({ processing, chat, bus, projectFiles, projectFile, libraryFile, libraryStatus, send, librarySync, settingsWrite, projectSave, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite } = consoleBus(call, { send: sendOn }));
|
||||
({ processing, chat, bus, projectFiles, projectFile, libraryFile, libraryStatus, send, librarySync, libraryRevert, settingsWrite, projectSave, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite } = consoleBus(call, { send: sendOn }));
|
||||
// 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 });
|
||||
@@ -107,7 +107,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, userOptions, federation: system?.federation ?? null });
|
||||
server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, libraryRevert, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, decisionWrite, userOptions, federation: system?.federation ?? null });
|
||||
} catch (err) { sso?.close(); throw err; }
|
||||
let testerServer = null;
|
||||
if (testerSocket) {
|
||||
|
||||
@@ -30,10 +30,10 @@ export function consoleBus(call, { send: sendOn = false, owner = null } = {}) {
|
||||
projectFiles: args => call('project.files', args),
|
||||
projectFile: args => call('project.file', args),
|
||||
libraryFile: args => call('library.file', args), libraryStatus: args => call('library.status', args),
|
||||
send: null, librarySync: null, settingsWrite: null, projectSave: null, nameWrite: null, assistantStop: null, instancesWrite: null, routesWrite: null, agentWrite: null,
|
||||
send: null, librarySync: null, libraryRevert: null, settingsWrite: null, projectSave: null, nameWrite: null, assistantStop: null, instancesWrite: null, routesWrite: null, agentWrite: null,
|
||||
};
|
||||
if (sendOn) {
|
||||
o.send = args => call('message.send', args); o.librarySync = args => call('library.sync', args); o.settingsWrite = args => call('limits.set', args);
|
||||
o.send = args => call('message.send', args); o.librarySync = args => call('library.sync', args); o.libraryRevert = args => call('library.revert', args); o.settingsWrite = args => call('limits.set', args);
|
||||
o.projectSave = args => call('project.file.save', args); o.nameWrite = args => call('name.set', args); o.assistantStop = args => call('assistant.stop', args);
|
||||
o.instancesWrite = args => call('instances.set', args); o.routesWrite = args => call('routes.set', args); o.agentWrite = agentCaller(call, AGENT_VERBS);
|
||||
}
|
||||
|
||||
@@ -23,3 +23,20 @@
|
||||
.chat-session-controls label { display: block; }
|
||||
.chat-session-controls select, .chat-session-controls input { max-width: 100%; }
|
||||
.chat-session-controls form { margin: .6rem 0; }
|
||||
|
||||
/* Row 225 (C2-10b, design 7.4): a reply's cites, a write notice and the library status line. The
|
||||
badge and labels are text, so they read the same without colour. */
|
||||
.chat-cites { list-style: none; margin: .5rem 0 0; padding-inline-start: 1rem; display: grid; gap: .35rem; }
|
||||
.chat-log .chat-cites li { padding: 0; border-bottom: 0; overflow-wrap: anywhere; font-size: .92rem; }
|
||||
.chat-cites code { font-family: var(--mono); font-size: .85rem; }
|
||||
.chat-scope, .chat-label { display: inline-block; padding: 0 .4rem; border: 1px solid var(--line); border-radius: var(--r); font-size: .78rem; }
|
||||
.chat-scope-canon { border-color: var(--success); color: var(--success); }
|
||||
.chat-scope-playground { border-color: var(--accent); color: var(--accent); }
|
||||
.chat-scope-web { border-color: var(--action); color: var(--action); }
|
||||
.chat-agent-written { color: var(--muted); }
|
||||
.chat-changed { border-color: var(--warning); color: var(--warning); }
|
||||
.chat-notice { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; white-space: normal; }
|
||||
.chat-revert-receipt { overflow-wrap: anywhere; }
|
||||
.chat-library { font-size: .9rem; margin-block: .5rem; overflow-wrap: anywhere; }
|
||||
.chat-library p { margin: 0; }
|
||||
.chat-library-problems ul { margin: .4rem 0 0; padding-inline-start: 1.25rem; }
|
||||
@@ -17,11 +17,22 @@
|
||||
if (!forms.has(key)) forms.set(key, { name: '', repo: '', instance: '', instanceChosen: false, receipt: '', pending: false });
|
||||
return forms.get(key);
|
||||
};
|
||||
// Row 225 (C2-10b, design 7.4): a reply renders through the library's renderReply, the one renderer the
|
||||
// Discord relay shares. Until it loads, or if it can't, a body shows as its text. Badges, the
|
||||
// agent-written label and "changed since" come from the bus's stored check (C2-06), never the body.
|
||||
const SHA = /^[0-9a-f]{40}$/, CODE = /^[a-z][a-z-]{0,63}$/, NAME = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,200}$/;
|
||||
// The library verbs name the business project, which is the chat project's repository link. These
|
||||
// codes mean that repository is no library, or this page may not read it: no status line at all.
|
||||
const NO_LIBRARY = ['project-not-declared', 'library-mapping-invalid', 'project-not-configured', 'not-configured', 'owner-only'];
|
||||
const libraryOf = (c, t) => { const repo = c?.projects.find(p => p.id === t?.project)?.repo; return typeof repo === 'string' && NAME.test(repo) ? repo : null; };
|
||||
let renderReply = null;
|
||||
import('/render.mjs').then(m => { renderReply = m.renderReply; if (current && !out()) paint(); }, () => {});
|
||||
const libraries = new Map(), reverts = new Map();
|
||||
class ChatError extends Error {
|
||||
constructor(code, status = 0, sent) { super(code); this.code = code; this.status = status; this.sent = sent; }
|
||||
}
|
||||
function forget() {
|
||||
generation++; controller?.abort(); states.clear(); forms.clear();
|
||||
generation++; controller?.abort(); states.clear(); forms.clear(); libraries.clear(); reverts.clear();
|
||||
if (current) current.container.replaceChildren();
|
||||
current = null;
|
||||
}
|
||||
@@ -80,11 +91,92 @@
|
||||
const reason = !Number.isSafeInteger(selected?.revision) ? 'Safe clear is unavailable until its dependency is installed.' : '';
|
||||
return `<section class="chat-session-controls" aria-label="Sessions"><label>Session<select id="chat-session">${c.sessions.map(r => `<option value="${esc(r.id)}"${r.id === c.session ? ' selected' : ''}>${txt(r.name)}${r.default ? ' (default)' : ''} — ${txt(r.state ?? 'status unavailable')}</option>`).join('')}</select></label><p id="chat-compactions">Compactions: ${txt(countLabel(selected?.compactions))}</p><p>${txt(selected?.state ?? 'Session status unavailable')}${UUID.test(selected?.successor) ? ` · <a href="${sessionPath(t.id, selected.successor)}">Open fresh successor</a>` : ''}</p><form id="chat-session-form" class="chat-create" data-key="session:${esc(t.id)}"><label>New session name<input id="chat-session-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending)}></label><button class="btn" type="submit"${disabled(f.pending)}>New session</button><p role="status">${txt(f.receipt)}</p></form><button class="btn" type="button" id="chat-clear"${disabled(clear.pending || !!reason || selected?.state && selected.state !== 'active')}>Clear context</button>${reason ? `<p>${txt(reason)}</p>` : ''}<p id="chat-clear-receipt" role="status">${txt(clear.receipt)}</p><form id="chat-operation-form"><label>Check clear operation<input id="chat-operation" value="${esc(clear.op ?? '')}" maxlength="160"${clear.pending ? ' disabled' : ''}></label><button class="btn" type="submit">Check status</button><button class="btn" type="button" id="chat-cancel"${disabled(clear.pending || !!reason || !clear.op || ['cancelled', 'failed'].includes(clear.record?.state) || clear.record?.start === 'started')}>Cancel clear</button></form></section>`;
|
||||
}
|
||||
// A cite is changed when the file's last commit at the served tip (`last`, read with the page) is not
|
||||
// its last commit at the cited one (`file`, stored with the check). Either one missing marks nothing.
|
||||
const changedBy = check => (cite, i) => {
|
||||
const e = Array.isArray(check?.citations) ? check.citations[i] : null;
|
||||
return SHA.test(e?.file ?? '') && SHA.test(e?.last ?? '') && e.file !== e.last;
|
||||
};
|
||||
function cites(list) {
|
||||
return `<ul class="chat-cites">${list.map(c => `<li>${c.badge ? `<span class="chat-scope chat-scope-${c.badge}">${txt(c.badge)}</span> ` : ''}${c.link ? `<a href="${esc(c.link)}" rel="noopener noreferrer" target="_blank">${txt(c.ref)}</a>` : `<code>${txt(c.ref)}</code>`} <q>${txt(c.quote)}</q>${c.agent ? ` <span class="chat-label chat-agent-written">agent-written: ${txt(c.agent)}</span>` : ''}${c.changed ? ' <span class="chat-label chat-changed">changed since</span>' : ''}</li>`).join('')}</ul>`;
|
||||
}
|
||||
function messageBody(m) {
|
||||
const r = renderReply?.(m.body, { check: m.check ?? null, name: m.from_role, changed: changedBy(m.check) });
|
||||
if (r?.kind === 'reply') return `<div class="chat-body">${txt(r.text)}</div>${r.citations.length ? cites(r.citations) : ''}`;
|
||||
if (r?.kind === 'notice') {
|
||||
const v = reverts.get(r.commit) ?? {};
|
||||
return `<div class="chat-body chat-notice"><span>${txt(r.text)}</span> <button class="btn" type="button" data-revert="${esc(r.commit)}"${disabled(v.pending)}>Revert</button> <span class="chat-revert-receipt" role="status">${txt(v.receipt ?? '')}</span></div>`;
|
||||
}
|
||||
return `<div class="chat-body">${txt(m.body)}</div>`;
|
||||
}
|
||||
const ago = iso => {
|
||||
const minutes = Math.floor((Date.now() - Date.parse(iso)) / 60000);
|
||||
return !Number.isFinite(minutes) ? 'at an unknown time' : minutes < 1 ? 'just now' : minutes < 60 ? `${minutes} min ago` : minutes < 2880 ? `${Math.floor(minutes / 60)} h ago` : `${Math.floor(minutes / 1440)} days ago`;
|
||||
};
|
||||
// library.status (C2-04): the served commit and when it was last fetched, a held divergence, the last
|
||||
// fetch error, the reply limits in force (C2-06) and the R-problems, listed one click away.
|
||||
function libraryLine(project) {
|
||||
const l = libraries.get(project);
|
||||
if (!l || l.hidden) return '';
|
||||
if (l.error) return `<div class="chat-library"><p role="status">Library status unavailable (${txt(l.error)}).</p></div>`;
|
||||
const v = l.value, parts = [];
|
||||
parts.push(SHA.test(v.served ?? '') ? `Library at <code>${txt(v.served.slice(0, 7))}</code>` : 'Library not served yet');
|
||||
if (typeof v.lastFetch === 'string') parts.push(`fetched ${txt(ago(v.lastFetch))}`);
|
||||
if (SHA.test(v.diverged?.commit ?? '')) parts.push(`<b>library-diverged</b> at <code>${txt(v.diverged.commit.slice(0, 7))}</code>`);
|
||||
if (CODE.test(v.lastError?.code ?? '')) parts.push(`fetch error <code>${txt(v.lastError.code)}</code>`);
|
||||
const limits = v.replyLimits;
|
||||
if (limits && typeof limits === 'object') {
|
||||
parts.push(`citations ${limits.requireCitation === true ? 'required' : 'optional'}${Number.isSafeInteger(limits.citationsPerReply) ? `, up to ${limits.citationsPerReply} per reply` : ''}`);
|
||||
const fell = Array.isArray(v.replyLimitsFallback) ? v.replyLimitsFallback.filter(k => typeof k === 'string') : [];
|
||||
if (fell.length) parts.push(`limits fell back: ${fell.map(k => `<code>${txt(k)}</code>`).join(', ')}`);
|
||||
}
|
||||
const problems = Array.isArray(v.problems) ? v.problems : [];
|
||||
if (!problems.length) parts.push('no problems');
|
||||
const list = problems.length ? `<details class="chat-library-problems"><summary>${problems.length} problem${problems.length === 1 ? '' : 's'}</summary><ul>${problems.map(p => `<li><code>${txt(p?.code)}</code> ${txt(p?.path)}${Number.isSafeInteger(p?.line) ? ':' + p.line : ''} ${txt(p?.message)}</li>`).join('')}</ul></details>` : '';
|
||||
return `<div class="chat-library"><p role="status">${parts.join(' · ')}</p>${list}</div>`;
|
||||
}
|
||||
async function loadLibrary(project, g, signal) {
|
||||
if (project === null) return;
|
||||
let entry;
|
||||
try {
|
||||
const res = await fetch('/api/library/status?project=' + encodeURIComponent(project), { cache: 'no-store', redirect: 'manual', signal });
|
||||
if (res.type === 'opaqueredirect' || res.status === 401) { signOut(); return; }
|
||||
const data = await res.json().catch(() => null);
|
||||
entry = res.ok && data && typeof data === 'object' && !Array.isArray(data) ? { value: data } : { error: CODE.test(data?.error ?? '') ? data.error : 'invalid-response' };
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') return;
|
||||
entry = { error: 'unreachable' };
|
||||
}
|
||||
if (g !== generation || out()) return;
|
||||
libraries.set(project, NO_LIBRARY.includes(entry.error) ? { hidden: true } : entry);
|
||||
paint();
|
||||
}
|
||||
// design (writes): Revert is library.revert {commit}, her act. A refusal shows its code; before
|
||||
// C2-02's verb exists that is the bus's refusal of the verb.
|
||||
async function revert(commit) {
|
||||
const c = current, t = c?.threads.find(t => t.id === c.id), v = reverts.get(commit) ?? {};
|
||||
const project = libraryOf(c, t);
|
||||
if (!c || !c.canSend || out() || v.pending || !SHA.test(commit) || project === null) return;
|
||||
if (!window.confirm(`Revert ${commit.slice(0, 7)}? This makes a new commit in the library that puts the file back as it was before it.`)) return;
|
||||
const unknown = 'Outcome unknown. Read the library status before trying again; nothing was retried.';
|
||||
reverts.set(commit, { pending: true, receipt: 'Reverting…' }); paint();
|
||||
let receipt;
|
||||
try {
|
||||
const res = await fetch('/api/library/revert', { method: 'POST', cache: 'no-store', redirect: 'manual', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ project, commit }) });
|
||||
if (res.type === 'opaqueredirect' || res.status === 401) { signOut(); return; }
|
||||
const data = await res.json().catch(() => null);
|
||||
receipt = res.ok && SHA.test(data?.commit ?? '') ? `Reverted in ${data.commit.slice(0, 7)}.`
|
||||
: data?.sent === false && typeof data.error === 'string' ? `Not reverted (${inert(data.error.slice(0, 80))}).` : unknown;
|
||||
} catch { receipt = unknown; }
|
||||
if (out()) return;
|
||||
reverts.set(commit, { pending: false, receipt });
|
||||
if (current) { paint(); void loadLibrary(project, generation); }
|
||||
}
|
||||
function conversation(t) {
|
||||
const s = state(current.key);
|
||||
const messages = s.rows.map(m => `<li data-message="${esc(m.id)}"><div class="chat-message-meta"><strong>${txt(m.from_role)}</strong> ${stamp(m.at)}</div><div class="chat-body">${txt(m.body)}</div></li>`).join('');
|
||||
const messages = s.rows.map(m => `<li data-message="${esc(m.id)}"><div class="chat-message-meta"><strong>${txt(m.from_role)}</strong> ${stamp(m.at)}</div>${messageBody(m)}</li>`).join('');
|
||||
const receipt = s.receipt;
|
||||
return `<section class="chat-conversation" data-thread="${esc(t.id)}" data-session="${esc(current.session)}"><p>${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}</p>${heading(t.name)}${sessionControls(t)}<p class="muted">Reading never wakes the assistant. An accepted message is not an answer.</p><button class="btn" type="button" id="chat-resume"${disabledSession(s.pending)}>Resume assistant</button><ol class="chat-log" tabindex="0" aria-label="Thread conversation">${messages || '<li class="muted">No messages yet.</li>'}</ol><div class="chat-paging">${s.more ? '<p>Showing earlier history. Read forward to reach the current conversation.</p><button class="btn" type="button" id="chat-next">Read next messages</button>' : '<p class="muted">Caught up to the last read.</p>'}<button class="btn" type="button" id="chat-new"${s.follow ? ' hidden' : ''}>New messages — go to end</button></div><form id="chat-send-form"><label for="chat-body">Message this thread</label><textarea id="chat-body" rows="4"${disabledSession(s.pending)}>${esc(s.draft)}</textarea><button class="btn primary" type="submit"${disabledSession(s.pending)}>Send</button></form><p id="chat-receipt" role="status">${txt(receipt)}</p></section>`;
|
||||
return `<section class="chat-conversation" data-thread="${esc(t.id)}" data-session="${esc(current.session)}"><p>${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}</p>${heading(t.name)}${libraryLine(libraryOf(current, t))}${sessionControls(t)}<p class="muted">Reading never wakes the assistant. An accepted message is not an answer.</p><button class="btn" type="button" id="chat-resume"${disabledSession(s.pending)}>Resume assistant</button><ol class="chat-log" tabindex="0" aria-label="Thread conversation">${messages || '<li class="muted">No messages yet.</li>'}</ol><div class="chat-paging">${s.more ? '<p>Showing earlier history. Read forward to reach the current conversation.</p><button class="btn" type="button" id="chat-next">Read next messages</button>' : '<p class="muted">Caught up to the last read.</p>'}<button class="btn" type="button" id="chat-new"${s.follow ? ' hidden' : ''}>New messages — go to end</button></div><form id="chat-send-form"><label for="chat-body">Message this thread</label><textarea id="chat-body" rows="4"${disabledSession(s.pending)}>${esc(s.draft)}</textarea><button class="btn primary" type="submit"${disabledSession(s.pending)}>Send</button></form><p id="chat-receipt" role="status">${txt(receipt)}</p></section>`;
|
||||
}
|
||||
function capture() {
|
||||
if (!current) return null;
|
||||
@@ -233,7 +325,11 @@
|
||||
}
|
||||
} catch (err) { if (err.name === 'AbortError') return; }
|
||||
}
|
||||
if (g === generation) paint(navigated);
|
||||
if (g === generation) {
|
||||
paint(navigated);
|
||||
const t = kind === 'thread' ? current.threads.find(t => t.id === id) : null;
|
||||
if (t) void loadLibrary(libraryOf(current, t), g, signal);
|
||||
}
|
||||
} catch (err) {
|
||||
if (g !== generation || out() || err.name === 'AbortError' || err.code === 'signed-out') return;
|
||||
current.error = readError(err);
|
||||
@@ -340,6 +436,8 @@
|
||||
if (event.target.id === 'chat-cancel') void cancelClear();
|
||||
if (event.target.id === 'chat-clear') void clearContext();
|
||||
if (event.target.id === 'chat-next') void refresh(true);
|
||||
const revertButton = event.target.closest?.('[data-revert]');
|
||||
if (revertButton) void revert(revertButton.dataset.revert);
|
||||
if (event.target.id === 'chat-new') { state(current.key ?? current.id).tailRequested = true; state(current.key ?? current.id).follow = true; paint(); }
|
||||
});
|
||||
document.addEventListener('change', event => {
|
||||
|
||||
@@ -32,6 +32,8 @@ const files = new Map([
|
||||
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'processing.js', 'agents.js', 'federation.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
|
||||
...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css', 'processing.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
|
||||
['/editor.js', ['editor.js', 'text/javascript; charset=utf-8']],
|
||||
// Row 225 (C2-10b): the one reply renderer, shared with the Discord relay, imported by chat.js.
|
||||
['/render.mjs', ['../../../library/src/render.mjs', 'text/javascript; charset=utf-8']],
|
||||
['/editor.css', ['editor.css', 'text/css; charset=utf-8']],
|
||||
['/icons.svg', ['icons.svg', 'image/svg+xml']],
|
||||
...[400, 500, 600, 700].map(w => [`/assets/fonts/manrope-${w}.woff2`, [`assets/fonts/manrope-${w}.woff2`, 'font/woff2']]),
|
||||
@@ -42,6 +44,7 @@ const files = new Map([
|
||||
const USER_FILES = new Map([
|
||||
['/', ['user.html', 'text/html; charset=utf-8']],
|
||||
...['user.js', 'chat.js', 'federation.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
|
||||
['/render.mjs', files.get('/render.mjs')],
|
||||
...['tokens.css', 'console.css', 'chat.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
|
||||
...[...files].filter(([p]) => p.startsWith('/assets/fonts/') || p === '/icons.svg'),
|
||||
]);
|
||||
@@ -596,12 +599,55 @@ async function projectFileRead(res, read, path, search) {
|
||||
return json(res, busStatus(code), { error: code, message: err instanceof BusReadError ? err.message : code });
|
||||
}
|
||||
}
|
||||
// Row 225 (C2-10b, design 7.4): the chat's library status line and a write notice's Revert. The status
|
||||
// is a read like the file list. The revert is her write (C2-02's `library.revert`), with the save's
|
||||
// boundary: header user, exact Origin, same-origin Fetch Metadata, JSON, strict keys. Until the verb
|
||||
// exists the bus refuses it, and the page shows that code.
|
||||
const REVERT_KEYS = ['project', 'commit'];
|
||||
async function libraryRevertSend(req, res, { libraryRevert, preview, log }) {
|
||||
let target = null;
|
||||
const line = outcome => log(`${new Date().toISOString()} library-revert user=${JSON.stringify(preview.user)} project=${quoted(target?.project)} commit=${quoted(target?.commit)} ${outcome}`);
|
||||
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
|
||||
if (!signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'a revert needs the Console\'s own Origin');
|
||||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'a revert needs same-origin Fetch Metadata');
|
||||
let value;
|
||||
try { value = JSON.parse((await body(req)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length !== REVERT_KEYS.length ||!REVERT_KEYS.every(k => typeof value[k] === 'string') || !PATH_SHAPE.test(value.project) || !COMMIT.test(value.commit)) {
|
||||
return refuse(400, 'invalid-request', 'a revert takes a project and a 40-hex commit, nothing else');
|
||||
}
|
||||
target = value;
|
||||
let result;
|
||||
try { result = await libraryRevert({ project: value.project, commit: value.commit }); } catch (err) {
|
||||
const { code, sent } = sendOutcome(err);
|
||||
return sent === false
|
||||
? refuse(busStatus(code), code, `not reverted: the bus refused it (${code}).`)
|
||||
: refuse(503, 'outcome-unknown', 'the revert may have been made; nothing was retried. Read the library status before trying again', 'unknown');
|
||||
}
|
||||
if (!COMMIT.test(result?.commit) || result.reverted !== value.commit || typeof result.path !== 'string') return refuse(503, 'outcome-unknown', 'the bus answered without the new commit; the revert may have been made', 'unknown');
|
||||
line(`commit=${result.commit}`);
|
||||
return json(res, 200, { project: value.project, path: result.path, reverted: result.reverted, commit: result.commit, at: new Date().toISOString() });
|
||||
}
|
||||
async function libraryStatusRead(res, read, search) {
|
||||
const query = new URLSearchParams(search);
|
||||
if ([...query.keys()].some(k => k !== 'project') || query.getAll('project').length !== 1 || !PATH_SHAPE.test(query.get('project')))
|
||||
return json(res, 400, { error: 'invalid-request', message: 'a library status read takes a project' });
|
||||
if (!read) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' });
|
||||
try {
|
||||
const result = await read({ project: query.get('project') });
|
||||
if (result === null || typeof result !== 'object' || Array.isArray(result) || result.project !== query.get('project')) throw new BusReadError('invalid-response');
|
||||
return json(res, 200, { ...result, at: new Date().toISOString() });
|
||||
} catch (err) {
|
||||
const code = err instanceof BusReadError ? err.code : 'invalid-response';
|
||||
return json(res, busStatus(code), { error: code, message: err instanceof BusReadError ? err.message : code });
|
||||
}
|
||||
}
|
||||
// `send` is the bus write (row 76): `args => Promise<{id}>`, only with the preview mode or `sso`.
|
||||
// `sso` is createSso() from sso.mjs (Q175 phase 1): the human listener signs people in itself.
|
||||
// `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, userOptions = null, federation = 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, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, processing = null, decisionWrite = null, userOptions = null, federation = 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');
|
||||
@@ -609,7 +655,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (sso !== null && (preview !== null || proof !== null)) throw new Error('--sso cannot be combined with --auth-header, --allow-user or --proof-*');
|
||||
if (tester && host !== '127.0.0.1') throw new Error('tester listener must bind 127.0.0.1');
|
||||
// Row 177 (#1675): chat is allowed. The tester listener's chat binds the tester, so the bus keeps it on tester threads.
|
||||
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 && [send, settingsWrite, projectSave, libraryRevert, 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.
|
||||
@@ -619,7 +665,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
// `userOptions` (FED R6, #1800) is `session => options`, the bus-backed options over that session
|
||||
// user's own transport (console-bus.mjs). Only a signed-in listener has user sessions.
|
||||
if (userOptions !== null && (sso === null || typeof userOptions !== 'function')) throw new Error('user sessions need --sso');
|
||||
const owner = { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, upstream, files };
|
||||
const owner = { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, upstream, files };
|
||||
const hub = federationView(federation);
|
||||
// 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 }))) {
|
||||
@@ -629,7 +675,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (send !== null && ((preview === null && sso === null) || typeof send !== 'function')) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso');
|
||||
const server = createServer(async (req, res) => {
|
||||
// A granted user's request rebinds these below; every other request keeps the owner's.
|
||||
let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, upstream, files } = owner, granted = false;
|
||||
let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, upstream, files } = owner, granted = false;
|
||||
res.setHeader('x-content-type-options', 'nosniff');
|
||||
res.setHeader('referrer-policy', 'no-referrer');
|
||||
// Under --sso the Sign out form's POST is redirected to the provider's end-session endpoint, and
|
||||
@@ -696,7 +742,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (!userRoute(path, req.method)) return json(res, 403, { error: 'owner-only', message: 'this part of the Console is the business owner\'s' });
|
||||
if (userOptions === null) return json(res, 503, { error: 'not-configured', message: 'the Console has no user transport' });
|
||||
({ bus, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing } = userOptions(session));
|
||||
reads = null; upstream = null; files = USER_FILES; granted = true;
|
||||
reads = null; upstream = null; libraryStatus = null; libraryRevert = null; files = USER_FILES; granted = true;
|
||||
}
|
||||
}
|
||||
if (named && preview !== null) {
|
||||
@@ -748,6 +794,11 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if ((!tester && (!send || !named)) || !routesWrite) return json(res, 403, { error: 'read-only', sent: false });
|
||||
return routesSend(req, res, { routesWrite, preview: writer, log });
|
||||
}
|
||||
if (path === '/api/library/revert') {
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
||||
if (!send || !libraryRevert || !named) return json(res, 403, { error: 'read-only', sent: false });
|
||||
return libraryRevertSend(req, res, { libraryRevert, preview: signer, log });
|
||||
}
|
||||
if (path === '/api/project-file' && req.method === 'POST') {
|
||||
if (!send || !projectSave || !named) return json(res, 403, { error: 'read-only', sent: false });
|
||||
return projectFileSend(req, res, { projectSave, preview: signer, log });
|
||||
@@ -816,6 +867,10 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
|
||||
return consoleRead(res, () => fieldTable(fieldsFile));
|
||||
}
|
||||
if (path === '/api/library/status') {
|
||||
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
|
||||
return libraryStatusRead(res, libraryStatus, search);
|
||||
}
|
||||
if (path === '/api/project-files' || path === '/api/project-file') {
|
||||
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
|
||||
return projectFileRead(res, path === '/api/project-file' ? projectFile : projectFiles, path, search);
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
// Row 225 (C2-10b, design 7.4): replies, cites, write notices and the library status line in the
|
||||
// Console chat. Fixture only: the chat and library answers are stubbed; /render.mjs is the real one.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { fixture, close } from './fixture.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
const P = '10000000-0000-4000-8000-000000000001';
|
||||
const A = '20000000-0000-4000-8000-000000000002';
|
||||
const C1 = 'a'.repeat(40), C2 = 'b'.repeat(40), C3 = 'c'.repeat(40), C4 = 'd'.repeat(40);
|
||||
const HOSTILE = '<img src=x onerror="window.injected=1">';
|
||||
const cite = (path, lines, quote, commit = C1, extra = {}) => ({ path, commit, lines, quote, ...extra });
|
||||
const reply = (text, citations) => JSON.stringify({ text, citations });
|
||||
const row = (id, body, check, from = 'assistant') => ({ id: `5000000${id}-0000-4000-8000-000000000000`, from_role: from, at: '2026-10-23T18:00:00.000Z', body, ...(check === undefined ? {} : { check }) });
|
||||
const ROWS = [
|
||||
row(1, 'Plain text answer.', undefined, 'jason'),
|
||||
row(2, reply("Mara's eyes are grey.", [
|
||||
cite('Book One/Chapters/Chapter 03.md', [41, 41], 'her grey eyes', C1, { as: 'canon' }),
|
||||
cite('Book One/Playground/What If.md', [5, 6], 'a storm'),
|
||||
cite('Notes/Eyes.md', [9, 10], 'green eyes'),
|
||||
{ url: 'https://example.invalid/tides', quote: 'spring tides' },
|
||||
{ url: 'http://example.invalid/plain', quote: 'plain http' },
|
||||
{ url: 'javascript:window.injected=2', quote: 'script' },
|
||||
]), { citations: [
|
||||
{ scope: 'canon', agent: null, file: C1, last: C1 },
|
||||
{ scope: 'playground', agent: null, file: C2, last: C3 },
|
||||
{ scope: 'supporting', agent: 'acme/scribe', file: C2 },
|
||||
{ scope: 'web', agent: null }, { scope: 'web', agent: null }, { scope: 'web', agent: null },
|
||||
] }),
|
||||
row(3, reply('Unchecked.', [cite('Notes/Eyes.md', [1, 1], HOSTILE)])),
|
||||
row(4, JSON.stringify({ notice: 'library-write', op: 'write', path: 'Notes/Eyes.md', commit: C4 }), undefined, 'scribe'),
|
||||
];
|
||||
const STATUS = { project: 'books', version: 1, branch: 'main', served: C3, lastFetch: 'FIVE-MINUTES-AGO',
|
||||
lastError: { code: 'library-fetch-failed', at: '2026-10-23T18:00:00.000Z' }, diverged: { commit: C2, at: '2026-10-23T18:00:00.000Z' },
|
||||
problems: [{ code: 'R1', path: 'Notes/Eyes.md', message: 'no heading', line: 3 }, { code: 'R2', path: HOSTILE, message: 'bad name' }] };
|
||||
const stub = `(() => {
|
||||
const real = fetch.bind(window);
|
||||
window.__reverts=[];window.__revertAnswers=[];window.__statusReads=[];window.__confirms=[];
|
||||
window.__status={status:200,body:${JSON.stringify(STATUS)}};
|
||||
window.confirm=text=>{window.__confirms.push(text);return window.__confirm!==false;};
|
||||
window.fetch=async (url,init={})=>{
|
||||
const path=String(url), method=init.method||'GET';
|
||||
const answer=(value,status=200)=>new Response(JSON.stringify(value),{status,headers:{'content-type':'application/json'}});
|
||||
if(path==='/api/config')return answer({send:!location.search.includes('readonly')});
|
||||
if(path.startsWith('/api/bus/'))return answer({rows:[],unread:0,at:new Date().toISOString()});
|
||||
if(path==='/api/business')return answer({projects:['books']});
|
||||
if(path.startsWith('/api/library/status')){
|
||||
window.__statusReads.push(path);
|
||||
const body={...window.__status.body};
|
||||
if(body.lastFetch==='FIVE-MINUTES-AGO')body.lastFetch=new Date(Date.now()-330000).toISOString();
|
||||
return answer(body,window.__status.status);
|
||||
}
|
||||
if(path==='/api/library/revert'){
|
||||
window.__reverts.push({method,body:JSON.parse(init.body),headers:init.headers,redirect:init.redirect});
|
||||
const a=window.__revertAnswers.shift();if(!a)throw new Error('No revert answer');
|
||||
if(a.reject)throw new TypeError('network');
|
||||
return answer(a.body,a.status||200);
|
||||
}
|
||||
if(!path.startsWith('/api/chat'))return real(url,init);
|
||||
const data=value=>answer({...value,at:new Date().toISOString()});
|
||||
if(path==='/api/chat/instances')return data({rows:[{instance:'assistant',name:'assistant'}]});
|
||||
if(path==='/api/chat/projects')return data({rows:[{id:'${P}',name:'Books',repo:window.__repo===undefined?'books':window.__repo}]});
|
||||
if(path.endsWith('/sessions'))return data({rows:[{id:'${A}',name:'default',default:true}]});
|
||||
if(path.startsWith('/api/chat/threads')&&!path.includes('/messages'))return data({rows:[{id:'${A}',project:'${P}',name:'alpha',instance:'assistant'}]});
|
||||
return data({rows:${JSON.stringify(ROWS)},after:null,hasMore:false});
|
||||
};
|
||||
})();`;
|
||||
async function setup(t, { suffix = '', before = '' } = {}) {
|
||||
const f = await fixture(); t.after(() => f.close());
|
||||
const server = await startServer({ port: 0, board: f.boardURL }); t.after(() => close(server));
|
||||
const b = await browser(); t.after(() => b.close());
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub + before });
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/${suffix}#/chat/threads/${A}`);
|
||||
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30));}throw new Error(${JSON.stringify(expression)});})()`);
|
||||
const text = selector => b.evaluate(`document.querySelector(${JSON.stringify(selector)})?.textContent ?? null`);
|
||||
const message = n => `.chat-log > li:nth-child(${n})`;
|
||||
await wait('document.querySelector(".chat-cites")');
|
||||
return { b, wait, text, message };
|
||||
}
|
||||
|
||||
test('chat replies: free text, cites with badges, the agent label, changed since and safe links', async t => {
|
||||
const { b, text, message } = await setup(t);
|
||||
assert.equal(await text(`${message(1)} .chat-body`), 'Plain text answer.');
|
||||
assert.equal(await text(`${message(2)} .chat-body`), "Mara's eyes are grey.");
|
||||
const cites = await b.evaluate(`[...document.querySelectorAll(${JSON.stringify(message(2) + ' .chat-cites > li')})].map(li => ({
|
||||
badge: li.querySelector('.chat-scope')?.textContent ?? null, ref: (li.querySelector('a') ?? li.querySelector('code'))?.textContent,
|
||||
href: li.querySelector('a')?.getAttribute('href') ?? null, rel: li.querySelector('a')?.getAttribute('rel') ?? null,
|
||||
quote: li.querySelector('q').textContent, agent: li.querySelector('.chat-agent-written')?.textContent ?? null,
|
||||
changed: !!li.querySelector('.chat-changed') }))`);
|
||||
assert.deepEqual(cites, [
|
||||
{ badge: 'canon', ref: 'Book One/Chapters/Chapter 03.md:41 @aaaaaaa', href: null, rel: null, quote: 'her grey eyes', agent: null, changed: false },
|
||||
{ badge: 'playground', ref: 'Book One/Playground/What If.md:5-6 @aaaaaaa', href: null, rel: null, quote: 'a storm', agent: null, changed: true },
|
||||
// `last` absent: nothing is marked changed (sage-3596).
|
||||
{ badge: 'supporting', ref: 'Notes/Eyes.md:9-10 @aaaaaaa', href: null, rel: null, quote: 'green eyes', agent: 'agent-written: acme/scribe', changed: false },
|
||||
{ badge: 'web', ref: 'https://example.invalid/tides', href: 'https://example.invalid/tides', rel: 'noopener noreferrer', quote: 'spring tides', agent: null, changed: false },
|
||||
{ badge: 'web', ref: 'http://example.invalid/plain', href: null, rel: null, quote: 'plain http', agent: null, changed: false },
|
||||
{ badge: 'web', ref: 'javascript:window.injected=2', href: null, rel: null, quote: 'script', agent: null, changed: false },
|
||||
]);
|
||||
// A reply with no check result: no badge, no label, never changed; its quote is inert text.
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll(${JSON.stringify(message(3) + ' .chat-scope, ' + message(3) + ' .chat-label')}).length`), 0);
|
||||
assert.equal(await text(`${message(3)} q`), HOSTILE);
|
||||
assert.equal(await b.evaluate('!!document.querySelector(".chat-log img") || window.injected'), false);
|
||||
});
|
||||
|
||||
test('chat replies: a write notice reads as its line with a Revert; a refused revert shows its code', async t => {
|
||||
const { b, wait, text, message } = await setup(t);
|
||||
assert.equal(await text(`${message(4)} .chat-notice > span`), 'scribe wrote Notes/Eyes.md @ddddddd');
|
||||
assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(message(4) + ' [data-revert]')}).dataset.revert`), C4);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("[data-revert]").length'), 1, 'only the notice has a Revert');
|
||||
// Cancelled at the confirm: nothing is sent.
|
||||
await b.evaluate('window.__confirm=false;document.querySelector("[data-revert]").click();true');
|
||||
assert.equal(await b.evaluate('window.__reverts.length'), 0);
|
||||
assert.match(await b.evaluate('window.__confirms[0]'), /^Revert ddddddd\?/);
|
||||
// Before C2-02 the bus refuses the verb; the page shows that code and doesn't retry.
|
||||
await b.evaluate(`window.__confirm=true;window.__revertAnswers.push({status:502,body:{error:'unknown-verb',message:'not reverted: the bus refused it (unknown-verb).',sent:false}});document.querySelector("[data-revert]").click();true`);
|
||||
await wait('document.querySelector(".chat-revert-receipt").textContent.startsWith("Not")');
|
||||
assert.equal(await text('.chat-revert-receipt'), 'Not reverted (unknown-verb).');
|
||||
assert.deepEqual(await b.evaluate('window.__reverts'), [{ method: 'POST', body: { project: 'books', commit: C4 }, headers: { 'content-type': 'application/json' }, redirect: 'manual' }]);
|
||||
for (const [answer, receipt] of [
|
||||
[{ status: 409, body: { error: 'library-revert-not-agent', sent: false } }, 'Not reverted (library-revert-not-agent).'],
|
||||
[{ status: 409, body: { error: 'stale-base', sent: false } }, 'Not reverted (stale-base).'],
|
||||
[{ status: 503, body: { error: 'outcome-unknown', sent: 'unknown' } }, 'Outcome unknown. Read the library status before trying again; nothing was retried.'],
|
||||
[{ reject: true }, 'Outcome unknown. Read the library status before trying again; nothing was retried.'],
|
||||
[{ body: { project: 'books', path: 'Notes/Eyes.md', reverted: C4, commit: C1 } }, 'Reverted in aaaaaaa.'],
|
||||
]) {
|
||||
const n = await b.evaluate('window.__reverts.length');
|
||||
await b.evaluate(`window.__revertAnswers.push(${JSON.stringify(answer)});document.querySelector("[data-revert]").click();true`);
|
||||
await wait(`window.__reverts.length===${n + 1} && !document.querySelector("[data-revert]").disabled`);
|
||||
assert.equal(await text('.chat-revert-receipt'), receipt);
|
||||
}
|
||||
assert.ok(await b.evaluate('window.__statusReads.length') >= 2, 'the status line is read again after a revert');
|
||||
});
|
||||
|
||||
test('chat replies: a read-only Console shows the notice with Revert disabled', async t => {
|
||||
const { b, message } = await setup(t, { suffix: '?readonly' });
|
||||
assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(message(4) + ' [data-revert]')}).disabled`), true);
|
||||
await b.evaluate('document.querySelector("[data-revert]").click();true');
|
||||
assert.equal(await b.evaluate('window.__reverts.length + window.__confirms.length'), 0);
|
||||
});
|
||||
|
||||
test('chat status line: served commit and age, divergence, fetch error and the problems one click away', async t => {
|
||||
const { b, wait, text } = await setup(t);
|
||||
await wait('document.querySelector(".chat-library")');
|
||||
assert.equal(await b.evaluate('window.__statusReads[0]'), '/api/library/status?project=books');
|
||||
assert.equal(await text('.chat-library [role=status]'), 'Library at ccccccc · fetched 5 min ago · library-diverged at bbbbbbb · fetch error library-fetch-failed');
|
||||
assert.equal(await text('.chat-library-problems summary'), '2 problems');
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".chat-library-problems li")].map(li => li.textContent)'),
|
||||
['R1 Notes/Eyes.md:3 no heading', `R2 ${HOSTILE} bad name`]);
|
||||
assert.equal(await b.evaluate('!!document.querySelector(".chat-library img")'), false);
|
||||
});
|
||||
|
||||
test('chat status line: limits when the status carries them, no problems, and refusals', async t => {
|
||||
const limits = { ...STATUS, lastError: null, diverged: null, problems: [], replyLimits: { requireCitation: true, citationsPerReply: 4 }, replyLimitsFallback: ['citationsPerReply'] };
|
||||
const { b, wait, text } = await setup(t, { before: `window.__status={status:200,body:${JSON.stringify(limits)}};` });
|
||||
await wait('document.querySelector(".chat-library")');
|
||||
assert.equal(await text('.chat-library [role=status]'), 'Library at ccccccc · fetched 5 min ago · citations required, up to 4 per reply · limits fell back: citationsPerReply · no problems');
|
||||
assert.equal(await b.evaluate('!!document.querySelector(".chat-library-problems")'), false);
|
||||
for (const [status, error, shown] of [[502, 'library-state-invalid', 'Library status unavailable (library-state-invalid).'], [502, 'Not A Code', 'Library status unavailable (invalid-response).'],
|
||||
[403, 'owner-only', null], [502, 'project-not-declared', null], [502, 'library-mapping-invalid', null], [503, 'project-not-configured', null], [503, 'not-configured', null]]) {
|
||||
await wait('!document.querySelector("#refresh").disabled');
|
||||
await b.evaluate(`window.__status={status:${status},body:{error:${JSON.stringify(error)}}};document.querySelector("#refresh").click();true`);
|
||||
await wait(`window.__statusReads.length && document.querySelector(".chat-conversation") && ${shown === null ? '!document.querySelector(".chat-library")' : `document.querySelector(".chat-library")?.textContent===${JSON.stringify(shown)}`}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('chat status line: a project with no repository link reads no library status', async t => {
|
||||
const { b } = await setup(t, { before: 'window.__repo=null;' });
|
||||
assert.equal(await b.evaluate('window.__statusReads.length'), 0);
|
||||
assert.equal(await b.evaluate('!!document.querySelector(".chat-library")'), false);
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
// Row 225 (C2-10b, design 7.4): the chat's library status read, a write notice's Revert and the
|
||||
// shared reply renderer served to the page. The revert is C2-02's `library.revert`; until that verb
|
||||
// exists the bus refuses it and the route reports the code.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { request } from 'node:http';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { startServer, userRoute } from '../src/serve.mjs';
|
||||
import { BusReadError } from '../src/bus.mjs';
|
||||
const preview = { host: 'console.example', header: 'x-user', user: 'jason' };
|
||||
const headers = { host: 'console.example', 'x-user': 'jason', origin: 'https://console.example', 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
|
||||
const C1 = 'a'.repeat(40), C2 = 'b'.repeat(40);
|
||||
const payload = { project: 'books', commit: C1 };
|
||||
const STATUS = { project: 'books', version: 1, branch: 'main', served: C2, lastFetch: '2026-10-23T18:00:00.000Z', lastError: null, diverged: null, problems: [] };
|
||||
async function setup(t, { enabled = true, revert, status, lines = [] } = {}) {
|
||||
const calls = { revert: [], status: [] };
|
||||
const server = await startServer({
|
||||
port: 0, preview, log: l => lines.push(l),
|
||||
send: enabled ? async () => ({}) : null,
|
||||
libraryStatus: status === null ? null : async args => { calls.status.push(args); return status ? status(args) : STATUS; },
|
||||
libraryRevert: enabled ? async args => { calls.revert.push(args); return revert ? revert(args) : { project: args.project, path: 'Notes/Eyes.md', reverted: args.commit, commit: C2 }; } : null,
|
||||
});
|
||||
t.after(() => new Promise(r => server.close(r)));
|
||||
return { calls, lines, port: server.address().port };
|
||||
}
|
||||
function call(s, { method = 'POST', path = '/api/library/revert', hs = headers, data = payload } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = request({ hostname: '127.0.0.1', port: s.port, path, method, headers: hs }, r => {
|
||||
let text = '';
|
||||
r.on('data', c => text += c);
|
||||
r.on('end', () => resolve({ status: r.statusCode, type: r.headers['content-type'], text, body: text.startsWith('{') ? JSON.parse(text) : null }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end(data === null ? undefined : typeof data === 'string' ? data : JSON.stringify(data));
|
||||
});
|
||||
}
|
||||
const get = (s, path, hs = headers) => call(s, { method: 'GET', path, hs, data: null });
|
||||
const refusal = code => { throw new BusReadError(code); };
|
||||
|
||||
test('a signed-in same-origin revert reaches exactly one library.revert and returns the new commit', async t => {
|
||||
const s = await setup(t), r = await call(s);
|
||||
assert.equal(r.status, 200, r.text);
|
||||
assert.deepEqual(s.calls.revert, [payload]);
|
||||
assert.deepEqual({ ...r.body, at: undefined }, { project: 'books', path: 'Notes/Eyes.md', reverted: C1, commit: C2, at: undefined });
|
||||
assert.ok(s.lines.some(l => l.includes('library-revert') && l.includes(`commit=${C2}`) && l.includes('project="books"')));
|
||||
});
|
||||
|
||||
test('a revert needs send, the named Host, the user, the exact Origin and same-origin Fetch Metadata', async t => {
|
||||
const off = await setup(t, { enabled: false });
|
||||
assert.deepEqual((await call(off)).body, { error: 'read-only', sent: false });
|
||||
const s = await setup(t);
|
||||
for (const [key, value, status] of [['host', 'wrong.example', 403], ['x-user', 'other', 401], ['origin', 'https://else.example', 403], ['sec-fetch-site', 'cross-site', 403],
|
||||
['origin', undefined, 403], ['sec-fetch-site', undefined, 403], ['x-user', ['jason', 'jason'], 401]]) {
|
||||
const hs = { ...headers };
|
||||
if (value === undefined) delete hs[key]; else hs[key] = value;
|
||||
const r = await call(s, { hs });
|
||||
assert.equal(r.status, status, key);
|
||||
assert.notEqual(r.body?.sent, true);
|
||||
}
|
||||
assert.equal((await call(s, { method: 'GET', data: null })).status, 405);
|
||||
assert.deepEqual(s.calls.revert, []);
|
||||
});
|
||||
|
||||
test('a revert takes a project and a 40-hex commit, nothing else, before the bus is asked', async t => {
|
||||
const s = await setup(t);
|
||||
for (const data of ['not json', 'null', '[]', '5', { project: 'books' }, { commit: C1 }, { ...payload, path: 'a.md' },
|
||||
{ project: 'books', commit: 'abc' }, { project: 'books', commit: C1.toUpperCase() }, { project: '../x', commit: C1 }, { project: 7, commit: C1 }]) {
|
||||
const r = await call(s, { data });
|
||||
assert.equal(r.status, 400, JSON.stringify(data));
|
||||
assert.equal(r.body.error, 'invalid-request');
|
||||
assert.equal(r.body.sent, false);
|
||||
}
|
||||
assert.deepEqual(s.calls.revert, []);
|
||||
});
|
||||
|
||||
test('a bus refusal is reported as not reverted with its code; before C2-02 that is the verb refusal', async t => {
|
||||
const codes = { 'unknown-verb': 502, 'library-revert-not-agent': 409, 'stale-base': 409, 'human-required': 403, 'project-not-configured': 503, 'invalid-request': 400 };
|
||||
for (const [code, status] of Object.entries(codes)) {
|
||||
const s = await setup(t, { revert: () => refusal(code) }), r = await call(s);
|
||||
assert.equal(r.status, status, code);
|
||||
assert.equal(r.body.error, code);
|
||||
assert.equal(r.body.sent, false);
|
||||
assert.match(r.body.message, new RegExp(`not reverted: the bus refused it \\(${code}\\)`));
|
||||
assert.equal(s.calls.revert.length, 1, 'never retried');
|
||||
}
|
||||
});
|
||||
|
||||
test('a revert whose answer is lost or unusable is reported unknown and is never retried', async t => {
|
||||
const answers = [() => refusal('outcome-unknown'), () => { throw new Error('socket closed'); }, () => ({ project: 'books', path: 'a.md', reverted: C1 }),
|
||||
() => ({ project: 'books', path: 'a.md', reverted: C2, commit: C2 }), () => ({ project: 'books', reverted: C1, commit: C2 }), () => null];
|
||||
for (const revert of answers) {
|
||||
const s = await setup(t, { revert }), r = await call(s);
|
||||
assert.equal(r.status, 503, revert.toString());
|
||||
assert.equal(r.body.error, 'outcome-unknown');
|
||||
assert.equal(r.body.sent, 'unknown');
|
||||
assert.equal(s.calls.revert.length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('the status read takes one project query and answers the bus result for that project', async t => {
|
||||
const s = await setup(t), r = await get(s, '/api/library/status?project=books');
|
||||
assert.equal(r.status, 200, r.text);
|
||||
assert.deepEqual(s.calls.status, [{ project: 'books' }]);
|
||||
assert.deepEqual({ ...r.body, at: undefined }, { ...STATUS, at: undefined });
|
||||
for (const path of ['/api/library/status', '/api/library/status?project=books&x=1', '/api/library/status?project=books&project=other', '/api/library/status?project=../x'])
|
||||
assert.equal((await get(s, path)).status, 400, path);
|
||||
assert.equal((await call(s, { path: '/api/library/status?project=books' })).status, 405);
|
||||
assert.equal(s.calls.status.length, 1);
|
||||
});
|
||||
|
||||
test('a status answer for another project or of the wrong shape is refused; a refusal keeps its status', async t => {
|
||||
for (const answer of [{ ...STATUS, project: 'other' }, null, [], 'books']) {
|
||||
const s = await setup(t, { status: () => answer }), r = await get(s, '/api/library/status?project=books');
|
||||
assert.equal(r.status, 502, JSON.stringify(answer));
|
||||
assert.equal(r.body.error, 'invalid-response');
|
||||
}
|
||||
for (const [code, status] of Object.entries({ 'human-required': 403, 'project-not-configured': 503, 'project-not-declared': 502, 'library-mapping-invalid': 502 })) {
|
||||
const s = await setup(t, { status: () => refusal(code) }), r = await get(s, '/api/library/status?project=books');
|
||||
assert.equal(r.status, status, code);
|
||||
assert.equal(r.body.error, code);
|
||||
}
|
||||
const none = await setup(t, { status: null });
|
||||
assert.equal((await get(none, '/api/library/status?project=books')).body.error, 'not-configured');
|
||||
});
|
||||
|
||||
test('the status read needs no send; a read-only Console still has no revert', async t => {
|
||||
const s = await setup(t, { enabled: false });
|
||||
assert.equal((await get(s, '/api/library/status?project=books')).status, 200);
|
||||
assert.equal((await call(s)).status, 403);
|
||||
});
|
||||
|
||||
test('library routes are the owner\'s: a granted user is refused them, and the tester never reverts', async t => {
|
||||
for (const [path, method] of [['/api/library/status', 'GET'], ['/api/library/revert', 'POST']]) assert.equal(userRoute(path, method), false, path);
|
||||
await assert.rejects(startServer({ port: 0, tester: true, testerSocket: '/nonexistent', libraryRevert: async () => ({}) }), /tester listener permits only/);
|
||||
});
|
||||
|
||||
test('the page gets the one library renderer, byte for byte, as a JavaScript module', async t => {
|
||||
const s = await setup(t), r = await get(s, '/render.mjs');
|
||||
assert.equal(r.status, 200);
|
||||
assert.match(r.type, /^text\/javascript/);
|
||||
assert.equal(r.text, readFileSync(new URL('../../library/src/render.mjs', import.meta.url), 'utf8'));
|
||||
});
|
||||
Reference in new issue
Block a user