Compare commits

..
66 changed files with 548 additions and 949 deletions

No files matched your search

+10 -4
View File
@@ -5458,8 +5458,14 @@ Row 299 (573, #1806, Gander, Filbert): before, the P10 band test timed a fault r
2026-10-24 | Goldie | Row325 / #1821 after (sage-3882 CLEAR) | Only Case1 projects key becomesstack; only old test70 expected key updated under mos-1564. New3-case test copies real .mosaic/project.json bytes, requires CLI project valid, rejects old key. Serialized shortTMP timeout-k10: new3/3, business264/264, old-key mutant killed/restored SHA9e43bfef, restored new3/3; all positive0 fail/skip/cancel. Manifest4/4 verifies including unchanged project document. Logs/load lines and mutant diff in BUILD.md; no queue writes/host action, Gladstone review/Sage gate pending.
2026-10-24 | Goldie | Row265 / #1770 before, recorded during authorized verification | C2-09b has no project room verbs. On goldie/row265 at ec86f2c84, add only room.open/send/read, pure exported leading-tag parser, README verb rows and new bus tests. Sage-3921 fixes EOF-tag and message-only timestamp readings; tester list/helpers/schema stay unchanged. Claim rev3814; Sage-3932 CLEAR for serialized new files, whole bus and twelve refusal mutants. No old-test edits authorized; stop rules apply.
2026-10-24 — Poe, row 208 / 193d (#1706), before: building Console shared list,
share controls and per-grant viewer read-only against approved design e2d161e2e.
Base ec86f2c84. Isolated tests and UI-guard mutants pending Sage CLEAR; no live action.
2026-10-24 | Goldie | Row265 / #1770 first slot STOP (sage-3932) | New parser14/14, rooms18/18 green; serialized bus stops at old project-runtime.test.mjs:14 import-closure pin, partial619 ok/1 not ok. New pure address.mjs import is absent from expected imports. Static trace also needs address.mjs in seat RUNNER_FILES. No old-test/product correction, no mutant/restored run, no green commit/push; old-pin and out-of-owned-files mount change sent to Sage for ruling. Audit prose missed the direct source pin despite raw grep hit; correction recorded in BUILD.md.
2026-10-24 | Goldie | Row265 / #1770 verified (mos-1567, sage-3942) | Approved correction adds solely address import pin + Row265 comment to project-runtime.test and one pure-source RUNNER_FILES mount; new block checks parser has no imports. Serialized fresh-short-TMP timeout-k10: unchanged sandbox10/10, parser15/15, rooms18/18, full bus957/957, twelve named refusal mutants killed (each Node1, driver0), restored parser15/15 + rooms18/18. All positives zero fail/skip/cancel; every mutation restored full broker SHA71b32ee4. Old first-red evidence retained, no other old-test change. Slot returned goldie-0034; Boyd packet explicitly includes mount-list review, candidate push next.
2026-10-24 — Poe, row 208 / 193d (#1706), after: Console shared list, share/unshare
controls and live per-grant viewer UI implemented. Webui 418/418 and plugins
119/119 green under sage-3925 CLEAR; four UI guard mutants killed with viewer
bus bypass refusals. Prior old-test draft regression fixed under sage-3918;
missing root oauth4webapi corrected by pinned npm ci under sage-3924. Evidence:
agents/poe/work/queue-208/BUILD.md. Independent reviews and Sage's global gate
remain; no live state, schema or bus policy change.
-134
View File
@@ -1,134 +0,0 @@
# Row 265 / #1770 — project rooms
Current result: verified under sage-3942; slot returned in goldie-0034. Full bus 957/957, sandbox 10/10, parser 15/15, rooms 18/18, twelve mutants killed, restored files green. Review/push packet below includes mos-1567 closure scope.
Goldie, 2026-10-24. Branch goldie/row265, base ec86f2c847f6154a0677c6ed23c87dabc2c665d0. Brief docs/plans/2026-10-20_case2-rows.md § C2-09b rooms at blob3a5ed91a4fb3462df67e6939ae6f18290ac9b453; design agents/gosalyn/work/case2/DESIGN.md §6. Sage-3915 transfers ownership from Rocko to Goldie and review from Dewey to Boyd. No old test edit authorized. All tests/mutants wait for Sage CLEAR; queued behind Poe, Bentina and Sage's326 gate.
## Design and clarified readings
A room is a bus project. Members are all loaded business instances whose definitions are in THREAD_DEFINITIONS, sorted by key. Canonical room threads have those keys as names and belong to the requesting human. open checks all name/instance/owner collisions before creating anything; it delegates missing inserts to existing thread.create dispatch, including default session metadata. send/read require the complete canonical set. No schema or migration change.
Sage-3921 settles two brief readings: the single captured timestamp applies to the room.send MESSAGE rows; existing route/delivery/event helpers keep their own timestamps in the same dispatch transaction. They are unchanged. A final EOF-terminated @name is a tag, so bare @continuity is invalid-request rather than a broadcast. Leading whitespace is accepted. Pure parseAddress returns {tags,body}, where body is the unaddressed remainder; room.send stores the ORIGINAL input unchanged. Export it from the bus index and expose a separate reusable test table for C2-10c parity.
send resolves tags against current #named results and member instance keys, deduplicates recipients, and refuses multiple member matches. It checks each target's current default-session fence and messaging rule, then inserts one normal INFO message, route and human.input per target under the existing request transaction. All message rows share one at. No tag broadcasts; tags after the first non-tag token remain plain text.
read includes all sessions of the caller's canonical threads, globally ordered by messages.seq. Each entry retains first-message fields plus thread/session/instance and adds ids (every grouped message id) and to (each instance once). Only consecutive human rows with identical at/body/class group, and a repeated instance starts a new entry, so same-millisecond identical sends remain separate. Paging limit counts merged entries (THREAD_PAGE defaults/max); clients continue after the last ids element. Cursor must belong to a room thread. Bounded raw scan limit*memberCount+1 completes requested groups without reading unbounded history. Sage-3921 accepts these routine choices as proposed in goldie-0030.
## Scope / steps
1. Read current thread.create/send/messages, transaction wrapper, naming and messaging helpers; audit source readers/pins. Claim265 via queue after next. Done/read, claim recorded at rev 3814 with op goldie-0265-start-1 (same-op replay confirmed).
2. Add parser + index export, only three room cases in broker, README verb rows and new bus tests/fixtures. Do not modify existing thread/messaging/ACL/helper behavior.
3. New tests cover all brief cases, atomicity across message/route/input rows, ownership/projects, permissions, paging/grouping and session fences. No suites or test runs before CLEAR.
4. Record exact refusal→test→mutant matrix here before requesting the slot; one targeted killed mutant per named refusal, restoration byte-checks. New files whole then bus suite under CLEAR, serial timeout-k10, fresh31-byte mktemp TMPDIR plus UTC/load/busy header.
5. Before in-review, grep packages/*/tests for broker.mjs/TESTER_VERBS and changed-file pins. Push own branch and hand tip/base/manifest directly to Sage/Boyd. Sage owns request posting/landing.
No Console/Discord/CLI room surface or cross-agent reply visibility added. No tester verb, role, schema or secret change. No host action or credential access.
## New tests and refusal / mutant matrix
Only new test files and a new reusable vector fixture are added. No pre-existing test changes. Parser table: address.test.mjs consumes address-cases.mjs, including final EOF tags, leading whitespace, tags after the first word, repetition and blank/tag-only remainder. Broker cases: rooms.test.mjs covers sorted/reused canonical threads, other-instance and other-owner collisions, all three verbs' human/tester gates, missing projects/member set, partial/foreign/wrong-instance rooms, key and current-name addressing, ambiguity/same-member name+key, unknown/non-member/old-name tags, tags-only bodies, one/many/broadcast targets, original bytes, shared message timestamp, routes/deliveries/human.input, denied last recipient, clearing last session, all-session merged reads, agent reply metadata, merged paging/default/max, foreign/noncanonical/absent cursors, and same-at repeated sends. Synthetic historical rows append valid rows; they do not rewrite evidence or clocks. The same-at batches repeat the first recipient at each boundary, proving identical sends remain distinct.
Each named refusal has one mutation of its enforcement, run against the entire new rooms.test.mjs file. The driver mutants.py stores the exact original→mutant diff in logs/Mxx-*.diff, logs UTC/load/busy/TMPDIR, accepts only Node exit 1 with the named target test failing, and restores/verifies full broker bytes and SHA256 in finally. No mutation is retained. Generic pre-existing request/schema/argument policies are exercised through existing code; the matrix names the refusals added or deliberately inherited by these room cases.
| Mutant | Refusal | Guard relaxed | Required failing new test |
| --- | --- | --- | --- |
| M01-human | human-required | remove common room human check | room verbs require the human |
| M02-tester | tester-refused | add the three room verbs to TESTER_VERBS | room verbs require the human (explicit absence pin) |
| M03-project | project-not-found | remove room project lookup check | room verbs refuse missing projects |
| M04-members | thread-instance-missing | allow empty member set | room verbs refuse missing projects |
| M05-collision | room-thread-name-taken | remove canonical owner/instance collision check | room.open preflights instance collisions |
| M06-open | room-not-open | remove missing canonical thread check | send/read refuse a missing canonical member |
| M07-body | invalid-request | allow empty parsed remainder | unknown or non-member addressees and tags-only bodies |
| M08-unknown | unknown-addressee | allow zero member matches | unknown or non-member addressees and tags-only bodies |
| M09-ambiguous | addressee-ambiguous | allow multiple member matches | a legacy name colliding with another member key |
| M10-messaging | message-sender-refused | omit room target messaging check | messaging denial on the last target |
| M11-cursor | message-not-found | unknown room cursor falls back to seq zero | room.read groups one send, pages |
| M12-fence | session-clearing | omit room target session fence | a clearing last member fences the whole send |
For M02, the existing human check remains a second barrier: the mutant deliberately relaxes only the tester allowlist; the new explicit absence assertion kills it without loosening or running the old pin. Existing helpers keep their full session-state behavior. No helper or schema mutation ships.
## Reader / hash audit
`rg -n 'broker\.mjs|TESTER_VERBS' packages/*/tests --glob '!fixtures/*'` produced reader-grep.txt (133 hits at audit time). These are imports except the concrete source read at bus/tests/limit-checks.test.mjs:323-326 (forbidden limit-check/token-limit imports) and the tester set assertions at bus/tests/tester-writes.test.mjs:18-32. The latter stays byte-for-byte unchanged. bus/tests/viewer.test.mjs:116 builds its coverage set from exported verb lists; rooms do not widen those lists. No old test needs an edit.
Pin check: recursively inspect packages/*/tests plus scripts/test-*.sh for changed-file mentions (broker.mjs, address.mjs, packages/bus/README.md, packages/bus/src/index.mjs) with literal 64-hex values within eight lines: pin-context-grep.txt records no hits. A direct same-line hash/digest/sha256 grep finds registrySha256 imports only, which hash host registry data rather than broker bytes. The independent channel-proof.test.mjs baselines cover proof/transport files, none changed here. Positive control: pin-positive-control.txt records actual schema.sql / SCHEMA_SHA256 / createHash references in bus/tests/schema.test.mjs and bus/src/store.mjs. Schema bytes are unchanged.
## Requested serialized slot plan (not run)
At each run: fresh `TMPDIR=$(mktemp -d /mnt/storage/scratch/tmp/XXXXXX)`, date -u, uptime, and `bash /mnt/storage/scratch/sage-busy-now.sh 2>/dev/null | tail -1` in the log. No concurrent test runs. Ignored root node_modules symlink targets the canonical checkout; it will not be committed.
1. `timeout -k 10 120 node --test packages/bus/tests/address.test.mjs` whole.
2. `timeout -k 10 120 node --test packages/bus/tests/rooms.test.mjs` whole.
3. `timeout -k 10 600 node --test --test-concurrency=1 packages/bus/tests/*.test.mjs` bus suite whole.
4. `python3 agents/goldie/work/queue-265/mutants.py`: M01 through M12 serially, each runs `timeout -k 10 120 node --test packages/bus/tests/rooms.test.mjs` whole, fresh TMPDIR/header; stop on a live mutant or wrong failure. Record each diff and restoration digest.
5. Restored address.test.mjs whole and rooms.test.mjs whole separately, same commands as 1/2 and fresh directories/header each.
Estimate 5–10 minutes; bus timeout ceiling 10 minutes, individual new/mutant ceilings 2 minutes each. On first new-test red, record/report it. On any old-test red or need for old-test change, stop and ask Sage. Static node --check for new modules/tests and git diff --check passed; no tests or mutants run yet.
## First slot STOP — sage-3932
2026-10-24T04:27Z: stopped on an old-test red. No old test changed or rerun, no mutant executed, no product adjustment after the failure, no commit/push claiming verification.
| Step | Exit | Counts / disposition | Log |
| --- | --- | --- | --- |
| 1 address whole | 0 | 14 pass, 0 fail/skip/cancel; 58.559 ms | logs/01-address.log |
| 2 rooms whole | 0 | 18 pass, 0 fail/skip/cancel; 1067.524 ms | logs/02-rooms.log |
| 3 bus whole | 1 (stopped) | partial 619 top-level ok, 1 not ok; not a completed-suite result | logs/03-bus.log |
| 4 twelve mutants | not run | stop rule | none |
| 5 restored files | not run | stop rule; no mutations were applied | none |
All three run headers use UTC/date, uptime, helper CLEAR and distinct 31-byte scratch TMPDIRs. Bus began04:25:22Z at load2.80/2.91/2.61, TMPDIR /mnt/storage/scratch/tmp/LaZAaK. Parser used /mnt/storage/scratch/tmp/rqcTH7; rooms has its own header. Explicit TAP reporter was selected for rooms/bus and the mutant driver so failure detection/counts are stable across Node26's default spec reporter; this changes reporting only.
The old red is project-runtime.test.mjs:12, assertion:14, test620, “Broker keeps the sandbox client import closure: only the listed imports, no lazy project import”. Actual imports include the new `import { parseAddress } from './address.mjs';`; the pinned array omits it. ERR_ASSERTION deepStrictEqual; individual duration2.469251ms. The full expected/actual/stack is in 03-bus.log. No other red was observed before stopping.
Reader-audit correction: reader-grep.txt:100 already contains project-runtime.test.mjs:13, a direct broker source read. The earlier prose incorrectly described the import-closure assertion as a normal import; I missed this old source pin during inspection. This is not a hash pin. Follow-up static trace finds seat/src/sandbox.mjs:50-60 RUNNER_FILES includes broker and landing but not address; seat/tests/sandbox.test.mjs:45-53 compares this mount list to the recursively imported source closure. A parser dependency therefore also needs the one new RUNNER_FILES path. Both an old-test pin change and this out-of-owned-files product change require Sage/Mos approval before edits. Recommended bounded change: add only the parser import to the project-runtime expected imports, add only packages/bus/src/address.mjs beside broker in RUNNER_FILES, and leave the generic sandbox closure test unchanged.
Broker bytes remain SHA256 71b32ee4afa322793aad81231441ee7badc3e5aff65ff008c6cc0f7f63b72781. git diff --check still passes. Slot returned in goldie-0032, pending ruling/new CLEAR; no reruns.
## mos-1567 approved closure correction / second slot request
Sage-3940 relays Mos's mos-1567 and widened row scope (sage-3939). Applied exactly the approved pre-existing-file edits: project-runtime.test.mjs adds two lines at17-18, one Row265 comment and the parser import in the pinned array, no other line changed (numstat2 added/0 deleted); seat/src/sandbox.mjs adds only `packages/bus/src/address.mjs` immediately after broker in RUNNER_FILES. seat/tests/sandbox.test.mjs stays byte-for-byte unchanged. The parser remains a separate pure module. A NEW block in address.test.mjs asserts `doesNotMatch(/\bimport\b/)` on its source, with a node:fs import in that new test file; no existing assertion is moved or weakened.
Review packet must explicitly call out the RUNNER_FILES mount-list entry for Boyd's review. This is a read-only source-file mount needed by the broker's static dependency, with no new module dependency or role/verb authority. The first-run import pin failure remains recorded above and in logs/03-bus.log; new logs will have rerun-prefixed names, preserving first-slot bytes.
Requested rerun order, awaiting CLEAR after Bentina: first `timeout -k 10 180 node --test --test-reporter=tap packages/seat/tests/sandbox.test.mjs` whole, then the original five steps again from step1: address whole (now15 cases), rooms whole, bus whole with concurrency1 (includes project-runtime whole), M01–M12 whole-room-file mutants, then restored address and rooms files separately. Explicit TAP reporter is used throughout for stable logs/kill matching; timeouts otherwise120 per new/mutant run and600 for bus. Each run still gets fresh short mktemp TMPDIR, date/uptime/busy header, serialized with the same stop rule. Estimate5–10minutes. Only static node --check and git diff --check run since the STOP; all pass. No new test run yet.
## Verified result — sage-3942 CLEAR, slot returned goldie-0034
All rerun positives exit 0 with zero failures, skips or cancellations. Full bus suite passed 957/957, including the approved project-runtime import pin. The unchanged sandbox test passed 10/10, including its recursive mount/import-closure assertion. New parser cases passed 15/15, rooms 18/18, and both passed again after restoration. No tests run after slot return.
| Run | Exit | Pass / fail | Duration ms | UTC / load / helper | Log |
| --- | --- | --- | --- | --- | --- |
| rerun-00-sandbox | 0 | 10 / 0 | 5526.027699 | Sat Oct 24 04:31:35 UTC 2026; 23:31:35 up 10 days, 14:51, 2 users, load average: 2.83, 2.91, 2.73; CLEAR | [log](logs/rerun-00-sandbox.log.gz) |
| rerun-01-address | 0 | 15 / 0 | 54.634817 | Sat Oct 24 04:31:45 UTC 2026; 23:31:45 up 10 days, 14:51, 2 users, load average: 2.54, 2.84, 2.71; CLEAR | [log](logs/rerun-01-address.log.gz) |
| rerun-02-rooms | 0 | 18 / 0 | 921.788987 | Sat Oct 24 04:31:52 UTC 2026; 23:31:52 up 10 days, 14:51, 2 users, load average: 2.42, 2.81, 2.70; CLEAR | [log](logs/rerun-02-rooms.log.gz) |
| rerun-03-bus | 0 | 957 / 0 | 118397.418425 | Sat Oct 24 04:32:01 UTC 2026; 23:32:01 up 10 days, 14:51, 2 users, load average: 2.28, 2.77, 2.68; CLEAR | [log](logs/rerun-03-bus.log.gz) |
| rerun-05-restored-address | 0 | 15 / 0 | 54.587943 | Sat Oct 24 04:35:02 UTC 2026; 23:35:02 up 10 days, 14:54, 2 users, load average: 3.10, 3.07, 2.83; CLEAR | [log](logs/rerun-05-restored-address.log.gz) |
| rerun-05-restored-rooms | 0 | 18 / 0 | 991.877991 | Sat Oct 24 04:35:11 UTC 2026; 23:35:11 up 10 days, 14:55, 2 users, load average: 3.17, 3.09, 2.84; CLEAR | [log](logs/rerun-05-restored-rooms.log.gz) |
Mutant driver exit 0; all twelve target tests killed their guard relaxation with Node exit 1. Every mutant log records exact bytes restored to broker SHA256 `71b32ee4afa322793aad81231441ee7badc3e5aff65ff008c6cc0f7f63b72781`. Driver receipt: [rerun-04-mutants-driver.log](logs/rerun-04-mutants-driver.log.gz).
| Mutant | Node exit | Pass / fail | Exact diff | Raw log |
| --- | --- | --- | --- | --- |
| M01-human | 1, killed | 16 / 2 | [diff](logs/M01-human.diff) | [log](logs/M01-human.log.gz) |
| M02-tester | 1, killed | 17 / 1 | [diff](logs/M02-tester.diff) | [log](logs/M02-tester.log.gz) |
| M03-project | 1, killed | 17 / 1 | [diff](logs/M03-project.diff) | [log](logs/M03-project.log.gz) |
| M04-members | 1, killed | 17 / 1 | [diff](logs/M04-members.diff) | [log](logs/M04-members.log.gz) |
| M05-collision | 1, killed | 14 / 4 | [diff](logs/M05-collision.diff) | [log](logs/M05-collision.log.gz) |
| M06-open | 1, killed | 17 / 1 | [diff](logs/M06-open.diff) | [log](logs/M06-open.log.gz) |
| M07-body | 1, killed | 17 / 1 | [diff](logs/M07-body.diff) | [log](logs/M07-body.log.gz) |
| M08-unknown | 1, killed | 16 / 2 | [diff](logs/M08-unknown.diff) | [log](logs/M08-unknown.log.gz) |
| M09-ambiguous | 1, killed | 17 / 1 | [diff](logs/M09-ambiguous.diff) | [log](logs/M09-ambiguous.log.gz) |
| M10-messaging | 1, killed | 17 / 1 | [diff](logs/M10-messaging.diff) | [log](logs/M10-messaging.log.gz) |
| M11-cursor | 1, killed | 17 / 1 | [diff](logs/M11-cursor.diff) | [log](logs/M11-cursor.log.gz) |
| M12-fence | 1, killed | 17 / 1 | [diff](logs/M12-fence.diff) | [log](logs/M12-fence.log.gz) |
## Final review scope and packet
Boyd reviews the three room cases, pure parser/export, README rows, new parser/room tests, and **the added read-only RUNNER_FILES mount for packages/bus/src/address.mjs**. mos-1567 authorizes that one sandbox source line and the sole old-test delta: project-runtime.test.mjs:17-18 adds exactly the Row265 comment and parser import pin (numstat 2 added / 0 deleted). Byte comparison against base after removing those additions proves all other old-test bytes unchanged. Removing the single RUNNER_FILES addition likewise restores sandbox.mjs to base. sandbox.test.mjs has no byte change. New address.test.mjs block proves the mounted parser source has no import word.
The widened changed-file hash-context audit includes sandbox.mjs: [pin-context-with-sandbox.txt](pin-context-with-sandbox.txt), zero 64-hex-near-reference hits. The original reader and positive-control receipts remain alongside it; the missed source pin and first STOP remain recorded, not concealed. No role/schema/tester allowlist or helper change ships. BUILD-LOG and SESSIONS only append tracking entries.
Base: `ec86f2c847f6154a0677c6ed23c87dabc2c665d0`. Branch: `goldie/row265`. MANIFEST.sha256 lists every changed source/test/tracking/evidence file except itself, with repository-relative paths, and is verified before commit/push. Raw first-red logs are retained separately from green reruns.
Evidence packaging: logs are stored losslessly as `.log.gz`; decompress with `gzip -cd`. Decompression was byte-compared with each raw run log before packing. This preserves the original failure/Node whitespace while keeping the committed diff whitespace-clean. The run driver recreates raw `.log` files when used. Mutant busy-check headers identify only their own Python driver PID3686656 as BUSY; positive runs report CLEAR. The driver held the assigned slot throughout, with serialized child tests.
@@ -1,51 +0,0 @@
996e9d0c1e7a8436691ce5d5d89b4eb940b07aafb3386f63a9847d203f43d998 BUILD-LOG.md
26b5821895a152ddac66a26f16585c6c48ec71d99ca7e7c2b9a4d5a71943c5b4 agents/goldie/work/queue-265/BUILD.md
1289c0f9f4e857590490a0972b5300cc8fb1a86e3ef79fe63e47472709e982f6 agents/goldie/work/queue-265/logs/01-address.log.gz
bc0ac95454ca4a4478641832e68caeb82bfe8b1fdc9abc0b80f1126ef42b7be5 agents/goldie/work/queue-265/logs/02-rooms.log.gz
459a45bd772576847338a438b27f4f578a2a660be744e1404fd239d192c6957c agents/goldie/work/queue-265/logs/03-bus.log.gz
c30cecb149e98d64f285e81a82d07a0a89dad5ea223f9031b543230615663bb5 agents/goldie/work/queue-265/logs/M01-human.diff
a75e02d91ff46fbfaf627beea943183b769e417bcb572bf11f778f0fca7fcf29 agents/goldie/work/queue-265/logs/M01-human.log.gz
b78b1c5b205272411710f56a981acf58dc09c46333369924b98c4c8657dc949c agents/goldie/work/queue-265/logs/M02-tester.diff
6a680dcb1db461cce3a3a2e066aa1245e4f84aa6754354058009bc0f6ebf4185 agents/goldie/work/queue-265/logs/M02-tester.log.gz
02a6cfab404405099fc39eb83413614c67a0c4c3f87cd23c3849a35d1055ddb0 agents/goldie/work/queue-265/logs/M03-project.diff
8b7cd0c568510e49b61fa14fce7b2680025b773eda884e0a84163d240a124e39 agents/goldie/work/queue-265/logs/M03-project.log.gz
18b2cb43f2799301cf934e4c327bc1a03e08ff8d11ff590cb9677c86dfd43ff0 agents/goldie/work/queue-265/logs/M04-members.diff
195f9ec57c9494552566d2f8b87f3b17a210b811f8b556dd65db89814e237816 agents/goldie/work/queue-265/logs/M04-members.log.gz
a773e214c76acf5285ac4ca17cdd90dd552ddc79157abe75ef6de7f67a0b6a70 agents/goldie/work/queue-265/logs/M05-collision.diff
1148cda3f6aaa7f6aeaab2f95c932114ddeac5bfd9318a6b77a4148079d486ed agents/goldie/work/queue-265/logs/M05-collision.log.gz
56400065c9cdb6305763b2bff8c0e06e9ae1223fa89b4467634a9951622d3fbe agents/goldie/work/queue-265/logs/M06-open.diff
b8f9bd9a5c488e604d0afe3feda4a2602359a94a49c7fdbd2f0f6a421b0ca1ce agents/goldie/work/queue-265/logs/M06-open.log.gz
2c1b745a15602cdcaf194131e4d6e0f8fa349e9712ce2e8cb6e695699e4ec19d agents/goldie/work/queue-265/logs/M07-body.diff
4857461e7a2ceb01a6446acb4708a732949a0d8d1a85fa2f6d10bedde6b9e11b agents/goldie/work/queue-265/logs/M07-body.log.gz
e3480e69d016caea6c1fbcdfa022b18214a10e8deca4409d0dc2f72261d2ee92 agents/goldie/work/queue-265/logs/M08-unknown.diff
3d0f347f21df3c388a4f39679d4694aadc9684ae8b8ad2d30bf0bb316e48dcb0 agents/goldie/work/queue-265/logs/M08-unknown.log.gz
62420ed20affcab256facee05414b8b760a4c79a60f60be0c06c88abb61ec6ae agents/goldie/work/queue-265/logs/M09-ambiguous.diff
14a18d61344df592e885c46ace34e0c59914f4fbe591fa99eeb3d81fa8ef8bf1 agents/goldie/work/queue-265/logs/M09-ambiguous.log.gz
0671bf77394f82df92252275171fd9edea2e5b2e7143387840d03bda544676bf agents/goldie/work/queue-265/logs/M10-messaging.diff
4202215d40999e0d1cc98ebfb5445f0c8fd4688e6ca968e85d225723076ca8a7 agents/goldie/work/queue-265/logs/M10-messaging.log.gz
042598ecb551e761d37af898f69e17ec137d2f39687dbf81ce5e1eeb98a38202 agents/goldie/work/queue-265/logs/M11-cursor.diff
deff3d0514c379bd3d9c6f6bbc60db59b9dce2baaafd6fa611711c98cc8365ce agents/goldie/work/queue-265/logs/M11-cursor.log.gz
780e70fbcb2328006c8d9ace75aa62fcd0d935b4d758b50b0f5c5d5c55604122 agents/goldie/work/queue-265/logs/M12-fence.diff
b629b41a39c028b70d4b847c351d248d6d70e8175d6d27f563b0ed8817f89642 agents/goldie/work/queue-265/logs/M12-fence.log.gz
1b8c3cec31b44ff018a1bc74fef95996bcbdd21d340e2d2605154ba645648040 agents/goldie/work/queue-265/logs/rerun-00-sandbox.log.gz
d18c5a9432de06ebec36c937da7dfb1a3e79dc397b33ff624058af351eee24dd agents/goldie/work/queue-265/logs/rerun-01-address.log.gz
d10b83ccec56e47d8c7c74d8304fc4fc87b8ea92a50098380cd02b8012c32d09 agents/goldie/work/queue-265/logs/rerun-02-rooms.log.gz
6ff5955fe5c85f51c1cf4daeb8eddd61ef1a30e170221f99bfb08da8304d7e4a agents/goldie/work/queue-265/logs/rerun-03-bus.log.gz
eeba0182f06cee0c06cd15ecf7ef43aaeffd7149b2202cc0653078c20bb46198 agents/goldie/work/queue-265/logs/rerun-04-mutants-driver.log.gz
5fe9fe35af60ef951c5c68b040aa9bb6067d9c746e143bb5782eb428acd4e4fe agents/goldie/work/queue-265/logs/rerun-05-restored-address.log.gz
2b3f3305ff5f6926a0269bd6c88d5f2e4a89727eefcf99625bebb51a70abb649 agents/goldie/work/queue-265/logs/rerun-05-restored-rooms.log.gz
f11ae40d923affad1936fe51d05981ac201a1051bc9f877d75fc1d544c6e38f1 agents/goldie/work/queue-265/mutants.py
e9cdf16670a16a14f3dd722a69c63a1785e4deb3608ee05d519b0ec8c1a9ca0b agents/goldie/work/queue-265/pin-context-grep.txt
491ba3bb25e918049249178f3c6a67b2b4f56a197f0a67059acb2420cd03b73c agents/goldie/work/queue-265/pin-context-with-sandbox.txt
784fb42bff187c0eebcf7756942ec3cf56f8faa7b54ce4ad96181b81fe46f0e9 agents/goldie/work/queue-265/pin-positive-control.txt
766ead519eefbde958bbdbf77bf858b10dd1e3d9dee8ab5d356fe821ce7602fe agents/goldie/work/queue-265/reader-grep.txt
6c3c44eb943f33d5bb547f542c77da4d6f04e738056466bc491c2f6fd5f67946 docs/SESSIONS.md
1ba33af1f42c88a5346d25fe915a44ee70a27fbc6b9ab880db0866cbdad5d45d packages/bus/README.md
232a1c77c2f17011c53b55fc6052f70985598210769d41d7c2c0b535328aad23 packages/bus/src/address.mjs
71b32ee4afa322793aad81231441ee7badc3e5aff65ff008c6cc0f7f63b72781 packages/bus/src/broker.mjs
ed57d48d2af4a225259e0a44c86a30b12792ec9f725dd9c89251569b7dc89e5a packages/bus/src/index.mjs
a6725ffe30d44981a9b9d3a3c319f7e96a46ec7bd74f0c8d869d9a798eca1a81 packages/bus/tests/address-cases.mjs
fae431bc3743b224f69da33c4cebe376b47257d67f52d25d3ab0cb59f224a607 packages/bus/tests/address.test.mjs
8594e94384c1c8b7234cfa8bbc17456cff86d9adef936cd1059a0d5a60e4546b packages/bus/tests/project-runtime.test.mjs
892c3709393fa8962710bc07565d1097bbd79ea1fbfda34af5f9385db34ed0fa packages/bus/tests/rooms.test.mjs
83019fa6b39ad9fd79b84627856b4dec82683d8261b8cba9b9ac840e0c489268 packages/seat/src/sandbox.mjs
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,10 +0,0 @@
--- broker.original
+++ M01-human
@@ -6423,7 +6423,6 @@
case 'room.open':
case 'room.send':
case 'room.read': {
- this.#human(s);
const opening = verb === 'room.open';
const sending = verb === 'room.send';
keys(a, opening ? ['project'] : sending ? ['project', 'body'] : ['project', 'after', 'limit'],
Binary file not shown.
@@ -1,12 +0,0 @@
--- broker.original
+++ M02-tester
@@ -122,7 +122,8 @@
// Row 175 (#1673, Q173): the test system's reads and two writes, name.set and project.create,
// each recorded with `tester` as its actor. The organization branch keeps its own list below.
// Row 177 (#1675, Q173): the tester's own threads, owner `tester`, and a stop of their runs.
-export const TESTER_VERBS = Object.freeze(['message.send', 'message.receive', 'message.read', 'inbox', 'agents', 'tasks', 'trail', 'decisions', 'messages', 'replies',
+export const TESTER_VERBS = Object.freeze([
+ 'room.open', 'room.send', 'room.read','message.send', 'message.receive', 'message.read', 'inbox', 'agents', 'tasks', 'trail', 'decisions', 'messages', 'replies',
'usage', 'names', 'limits', 'plugins', 'instances', 'project.list', 'thread.list', 'name.set', 'project.create', 'credentials',
'thread.create', 'thread.open', 'thread.send', 'thread.messages', 'assistant.stop',
// Row 194 (#1692, D4.1): launch and terminate, and the read of the requests they make.
Binary file not shown.
@@ -1,10 +0,0 @@
--- broker.original
+++ M03-project
@@ -6429,7 +6429,6 @@
keys(a, opening ? ['project'] : sending ? ['project', 'body'] : ['project', 'after', 'limit'],
sending ? ['project', 'body'] : ['project']);
const project = identifier(a.project);
- if (!this.#store.get('SELECT 1 FROM projects WHERE id=? AND business=?', project, s.business)) fail('project-not-found');
const members = Object.keys(b.roles).filter((instance) => this.#threaded(s.business, instance)).sort();
if (!members.length) fail('thread-instance-missing');
const owner = this.#owner(s);
Binary file not shown.
@@ -1,10 +0,0 @@
--- broker.original
+++ M04-members
@@ -6431,7 +6431,6 @@
const project = identifier(a.project);
if (!this.#store.get('SELECT 1 FROM projects WHERE id=? AND business=?', project, s.business)) fail('project-not-found');
const members = Object.keys(b.roles).filter((instance) => this.#threaded(s.business, instance)).sort();
- if (!members.length) fail('thread-instance-missing');
const owner = this.#owner(s);
// Preflight all collisions/missing threads before open creates or send writes anything.
const threads = members.map((instance) => {
Binary file not shown.
@@ -1,11 +0,0 @@
--- broker.original
+++ M05-collision
@@ -6436,8 +6436,6 @@
// Preflight all collisions/missing threads before open creates or send writes anything.
const threads = members.map((instance) => {
const t = this.#store.get('SELECT * FROM threads WHERE project=? AND name=?', project, instance);
- if (t && (t.instance !== instance || t.owner !== owner))
- fail(opening ? 'room-thread-name-taken' : 'room-not-open');
if (!t && !opening) fail('room-not-open');
return t ?? { instance, name: instance };
});
@@ -1,10 +0,0 @@
--- broker.original
+++ M06-open
@@ -6438,7 +6438,6 @@
const t = this.#store.get('SELECT * FROM threads WHERE project=? AND name=?', project, instance);
if (t && (t.instance !== instance || t.owner !== owner))
fail(opening ? 'room-thread-name-taken' : 'room-not-open');
- if (!t && !opening) fail('room-not-open');
return t ?? { instance, name: instance };
});
if (opening) {
Binary file not shown.
@@ -1,10 +0,0 @@
--- broker.original
+++ M07-body
@@ -6450,7 +6450,6 @@
if (sending) {
string(a.body, 32768);
const addressed = parseAddress(a.body);
- if (!addressed.body.trim()) fail('invalid-request');
const selected = new Set();
for (const tag of addressed.tags) {
const candidates = new Set([this.#named(s.business, tag), tag].filter((instance) => members.includes(instance)));
Binary file not shown.
@@ -1,10 +0,0 @@
--- broker.original
+++ M08-unknown
@@ -6454,7 +6454,6 @@
const selected = new Set();
for (const tag of addressed.tags) {
const candidates = new Set([this.#named(s.business, tag), tag].filter((instance) => members.includes(instance)));
- if (!candidates.size) fail('unknown-addressee');
if (candidates.size > 1) fail('addressee-ambiguous');
selected.add([...candidates][0]);
}
Binary file not shown.
@@ -1,10 +0,0 @@
--- broker.original
+++ M09-ambiguous
@@ -6455,7 +6455,6 @@
for (const tag of addressed.tags) {
const candidates = new Set([this.#named(s.business, tag), tag].filter((instance) => members.includes(instance)));
if (!candidates.size) fail('unknown-addressee');
- if (candidates.size > 1) fail('addressee-ambiguous');
selected.add([...candidates][0]);
}
const targets = threads.filter((t) => !addressed.tags.length || selected.has(t.instance));
@@ -1,10 +0,0 @@
--- broker.original
+++ M10-messaging
@@ -6462,7 +6462,6 @@
const sessions = targets.map((t) => {
const session = this.#defaultSession(t.id);
this.#sessionFence(session);
- this.#checkMessaging(s.business, 'human', t.instance);
return session;
});
const at = this.#now();
@@ -1,11 +0,0 @@
--- broker.original
+++ M11-cursor
@@ -6488,7 +6488,7 @@
`SELECT m.seq FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE m.id=? AND m.business=? AND mt.thread IN (${placeholders})`,
identifier(a.after), s.business, ...tids,
)?.seq;
- if (after === undefined) fail('message-not-found');
+ if (after === undefined) after = 0;
}
// At most one row per instance joins a group; one lookahead row completes the last page.
const rows = this.#store.all(
Binary file not shown.
@@ -1,10 +0,0 @@
--- broker.original
+++ M12-fence
@@ -6461,7 +6461,6 @@
const targets = threads.filter((t) => !addressed.tags.length || selected.has(t.instance));
const sessions = targets.map((t) => {
const session = this.#defaultSession(t.id);
- this.#sessionFence(session);
this.#checkMessaging(s.business, 'human', t.instance);
return session;
});
Binary file not shown.
-67
View File
@@ -1,67 +0,0 @@
#!/usr/bin/env python3
"""Slot-only runner. Every mutation restores and verifies the complete broker bytes."""
from pathlib import Path
import hashlib, os, re, subprocess, sys
ROOT = Path(__file__).resolve().parents[4]
SOURCE = ROOT / 'packages/bus/src/broker.mjs'
OUT = Path(__file__).resolve().parent / 'logs'
MUTANTS = [
('M01-human', 'human-required', 'room verbs require the human', ' this.#human(s);\n', ''),
('M02-tester', 'tester-refused', 'room verbs require the human', None, None),
('M03-project', 'project-not-found', 'room verbs refuse missing projects', " if (!this.#store.get('SELECT 1 FROM projects WHERE id=? AND business=?', project, s.business)) fail('project-not-found');\n", ''),
('M04-members', 'thread-instance-missing', 'room verbs refuse missing projects', " if (!members.length) fail('thread-instance-missing');\n", ''),
('M05-collision', 'room-thread-name-taken', 'room.open preflights instance collisions', " if (t && (t.instance !== instance || t.owner !== owner))\n fail(opening ? 'room-thread-name-taken' : 'room-not-open');\n", ''),
('M06-open', 'room-not-open', 'send/read refuse a missing canonical member', " if (!t && !opening) fail('room-not-open');\n", ''),
('M07-body', 'invalid-request', 'unknown or non-member addressees and tags-only bodies', " if (!addressed.body.trim()) fail('invalid-request');\n", ''),
('M08-unknown', 'unknown-addressee', 'unknown or non-member addressees and tags-only bodies', " if (!candidates.size) fail('unknown-addressee');\n", ''),
('M09-ambiguous', 'addressee-ambiguous', 'a legacy name colliding with another member key', " if (candidates.size > 1) fail('addressee-ambiguous');\n", ''),
('M10-messaging', 'message-sender-refused', 'messaging denial on the last target', " this.#checkMessaging(s.business, 'human', t.instance);\n", ''),
('M11-cursor', 'message-not-found', 'room.read groups one send, pages', " if (after === undefined) fail('message-not-found');\n", ' if (after === undefined) after = 0;\n'),
('M12-fence', 'session-clearing', 'a clearing last member fences the whole send', ' this.#sessionFence(session);\n', ''),
]
def sha(data): return hashlib.sha256(data).hexdigest()
def main():
OUT.mkdir(exist_ok=True)
original = SOURCE.read_bytes()
text = original.decode()
start = text.index(" case 'room.open':")
end = text.index(" // A thread belongs", start)
block = text[start:end]
for name, refusal, expected, old, new in MUTANTS:
if old is None:
needle = 'export const TESTER_VERBS = Object.freeze(['
assert text.count(needle) == 1
changed = text.replace(needle, needle + "\n 'room.open', 'room.send', 'room.read',", 1)
else:
assert block.count(old) == 1, (name, block.count(old))
changed = text[:start] + block.replace(old, new, 1) + text[end:]
# Save conventional original→mutant diff without relying on shell interpolation.
import difflib
(OUT / f'{name}.diff').write_text(''.join(difflib.unified_diff(text.splitlines(True), changed.splitlines(True), fromfile='broker.original', tofile=name)))
tmp = subprocess.check_output(['mktemp', '-d', '/mnt/storage/scratch/tmp/XXXXXX'], text=True).strip()
env = {**os.environ, 'TMPDIR': tmp}
log = OUT / f'{name}.log'
try:
SOURCE.write_text(changed)
with log.open('w') as stream:
for command in [['date', '-u'], ['uptime'], ['bash', '-c', 'bash /mnt/storage/scratch/sage-busy-now.sh 2>/dev/null | tail -1']]:
subprocess.run(command, cwd=ROOT, env=env, stdout=stream, stderr=subprocess.STDOUT, check=True)
stream.write(f'TMPDIR={tmp}\nmutant={name} refusal={refusal} original_sha256={sha(original)}\n')
stream.flush()
result = subprocess.run(['timeout', '-k', '10', '120', 'node', '--test', '--test-reporter=tap', 'packages/bus/tests/rooms.test.mjs'],
cwd=ROOT, env=env, stdout=stream, stderr=subprocess.STDOUT)
output = log.read_text()
killed = result.returncode == 1 and re.search(r'(?m)^not ok \d+ - ' + re.escape(expected), output)
print(f'{name}: exit={result.returncode} target_killed={bool(killed)} log={log}', flush=True)
if not killed: return 1
finally:
SOURCE.write_bytes(original)
assert SOURCE.read_bytes() == original
assert sha(SOURCE.read_bytes()) == sha(original)
with log.open('a') as stream: stream.write(f'RESTORED sha256={sha(original)}\n')
return 0
if __name__ == '__main__': sys.exit(main())
@@ -1 +0,0 @@
No changed-file mention has a literal 64-hex digest within eight lines.
@@ -1 +0,0 @@
No changed-product-file mention has a literal 64-hex digest within eight lines.
@@ -1,6 +0,0 @@
packages/bus/src/store.mjs:15:const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
packages/bus/src/store.mjs:16:export const SCHEMA_SHA256 = 'a2ba3a46f12e56139ecae9e55227d00847842aedec50c1d92aaae28844ca48e7';
packages/bus/src/store.mjs:106:const hex = (value) => createHash('sha256').update(value).digest('hex');
packages/bus/src/store.mjs:107:if (hex(ddl) !== SCHEMA_SHA256) throw Error('schema-source-mismatch');
packages/bus/tests/schema.test.mjs:17: db.exec(readFileSync(new URL('../schema.sql', import.meta.url), 'utf8'));
packages/bus/tests/schema.test.mjs:90: const hex = (s) => createHash('sha256').update(s).digest('hex');
@@ -1,133 +0,0 @@
packages/bus/tests/session-compactions.test.mjs:9:import { Broker } from '../src/broker.mjs';
packages/bus/tests/console-decide.test.mjs:14:import { Broker, CONSOLE_VERBS } from '../src/broker.mjs';
packages/harness/tests/runner.test.mjs:14:import { Broker, BusError } from "../../bus/src/broker.mjs";
packages/bus/tests/project-files.test.mjs:8:import { PROJECT_FILE_VERBS } from '../src/broker.mjs';
packages/cli/tests/lifecycle.test.mjs:19:import { BusError } from "../../bus/src/broker.mjs";
packages/bus/tests/lifecycle-verbs.test.mjs:11:import { Broker, LIFECYCLE_REQUEST_MS, TESTER_VERBS } from '../src/broker.mjs';
packages/bus/tests/lifecycle-verbs.test.mjs:74: for (const verb of ['instance.launch', 'instance.end', 'instance.request']) assert.ok(TESTER_VERBS.includes(verb), verb);
packages/bus/tests/lifecycle-verbs.test.mjs:75: assert.equal(TESTER_VERBS.includes('instance.regenerate'), false);
packages/bus/tests/tasks.test.mjs:9:import { Broker, BusError, TASK_VERBS } from '../src/broker.mjs';
packages/harness/tests/tools.test.mjs:4:import { ORGANIZATION_KEYS as BROKER_ORGANIZATION_KEYS, TASK_PAGE, TASK_PAGE_MAX } from "../../bus/src/broker.mjs";
packages/seat/tests/session.test.mjs:11:import { Broker } from "../../bus/src/broker.mjs";
packages/bus/tests/decision-route.test.mjs:12:import { Broker } from '../src/broker.mjs';
packages/cli/tests/thread-launch.test.mjs:19:import { BusError } from "../../bus/src/broker.mjs";
packages/seat/tests/sandbox.test.mjs:15:import { Broker } from "../../bus/src/broker.mjs";
packages/bus/tests/host-run.test.mjs:16:import { Broker, ORGANIZATION_KEYS } from '../src/broker.mjs';
packages/bus/tests/host-run.test.mjs:181: // organization.set reaches the writer as limits.set (broker.mjs:1379).
packages/cli/tests/helpers.mjs:10:import { BusError, Broker } from "../../bus/src/broker.mjs";
packages/tasks/tests/fields.test.mjs:10:import { TASK_VERBS } from '../../bus/src/broker.mjs';
packages/tasks/tests/fields.test.mjs:226: // decision back to that role (broker.mjs, decision.raise).
packages/webui/tests/instances.test.mjs:15:import { BusError } from '../../bus/src/broker.mjs';
packages/cli/tests/tester-threads.test.mjs:14:import { BusError, Broker } from "../../bus/src/broker.mjs";
packages/bus/tests/staff-request.test.mjs:8:import { Broker } from '../src/broker.mjs';
packages/tasks/tests/world.mjs:5:import { Broker } from '../../bus/src/broker.mjs';
packages/cli/tests/processing-cli.test.mjs:7:import { Broker } from '../../bus/src/broker.mjs';
packages/bus/tests/grant-host.test.mjs:10:import { Broker, registrySha256 } from '../src/broker.mjs';
packages/business/tests/workflow-roles.test.mjs:115: // Q169 (broker.mjs END_PROTECTED, #hardWait): revoking the assistant is a hard wait as well.
packages/business/tests/workflow-roles.test.mjs:136: // sage-0709: only recordLand spends a decision (broker.mjs #spent). checkLand refuses decision-consumed after
packages/cli/tests/thread-revoke.test.mjs:18:import { BusError } from "../../bus/src/broker.mjs";
packages/bus/tests/tester-writes.test.mjs:10:import { Broker, TESTER_VERBS, TESTER_ORGANIZATION_VERBS } from '../src/broker.mjs';
packages/bus/tests/tester-writes.test.mjs:18:test('row 175: TESTER_VERBS and the organization branch tester set are pinned', () => {
packages/bus/tests/tester-writes.test.mjs:19: assert.deepEqual([...TESTER_VERBS], [
packages/bus/tests/tester-writes.test.mjs:32: assert.ok(Object.isFrozen(TESTER_VERBS) && Object.isFrozen(TESTER_ORGANIZATION_VERBS));
packages/cli/tests/stack-release.test.mjs:1993: for(const [f,text] of [['packages/cli/src/cli.mjs',id===A?'// fixture host from before row 163\nprocess.exit(1);\n':script(rHost)],['packages/webui/src/cli.mjs',script(rConsole)],['packages/bus/src/store.mjs',rStore(v,froms)],['packages/bus/src/migrate-schema.mjs',script(rMigrate)],['packages/bus/src/broker.mjs','export class BusError extends Error { constructor(code) { super(code); this.code = code; } }\n']]){mkdirSync(dirname(join(d,f)),{recursive:true});writeFileSync(join(d,f),text)}
packages/cli/tests/session-cli.test.mjs:94: const { Broker } = await import('../../bus/src/broker.mjs');
packages/cli/tests/session-launch.test.mjs:21:import { BusError } from "../../bus/src/broker.mjs";
packages/cli/tests/revoke-window.test.mjs:19:import { BusError } from "../../bus/src/broker.mjs";
packages/bus/tests/chief-of-staff.test.mjs:10:import { Broker, ACTIONS } from '../src/broker.mjs';
packages/cli/tests/instances.test.mjs:13:import { BusError } from "../../bus/src/broker.mjs";
packages/cli/tests/thread-cli.test.mjs:14:import { BusError, Broker } from "../../bus/src/broker.mjs";
packages/bus/tests/processing.test.mjs:6:import { Broker } from '../src/broker.mjs';
packages/cli/tests/idle-stop-race.test.mjs:17:import { BusError } from "../../bus/src/broker.mjs";
packages/cli/tests/tester-reads.test.mjs:12:import { BusError } from "../../bus/src/broker.mjs";
packages/cli/tests/session-clear.test.mjs:18:import { BusError } from "../../bus/src/broker.mjs";
packages/bus/tests/names.test.mjs:8:import { Broker } from '../src/broker.mjs';
packages/cli/tests/compactions-launch.test.mjs:24:import { BusError } from "../../bus/src/broker.mjs";
packages/cli/tests/tester.test.mjs:7:import { BusError, Broker } from "../../bus/src/broker.mjs";
packages/bus/tests/reload.test.mjs:9:import { Broker } from '../src/broker.mjs';
packages/bus/tests/agents.test.mjs:9:import { Broker } from '../src/broker.mjs';
packages/bus/tests/end-launch.test.mjs:9:import { Broker } from '../src/broker.mjs';
packages/webui/tests/decide-browser.test.mjs:14:import { Broker } from '../../bus/src/broker.mjs';
packages/bus/tests/credential-verbs.test.mjs:11:import { Broker, CREDENTIAL_VERBS } from '../src/broker.mjs';
packages/webui/tests/send-browser.test.mjs:13:import { Broker } from '../../bus/src/broker.mjs';
packages/bus/tests/launch-notice.test.mjs:8:import { Broker } from '../src/broker.mjs';
packages/bus/tests/messages.test.mjs:3:import { Broker } from '../src/broker.mjs';
packages/bus/tests/workflow-roles.test.mjs:11:import { Broker } from '../src/broker.mjs';
packages/bus/tests/library-sync.test.mjs:11:import { Broker, LIBRARY_VERBS } from '../src/broker.mjs';
packages/bus/tests/credential-status.test.mjs:12:import { Broker } from '../src/broker.mjs';
packages/bus/tests/gitea-grant-docker.test.mjs:15:import { Broker, GRANT_EVIDENCE } from '../src/broker.mjs';
packages/bus/tests/thread-migration.test.mjs:11:import { Broker } from '../src/broker.mjs';
packages/bus/tests/comment-cursors.test.mjs:7:import { Broker } from '../src/broker.mjs';
packages/bus/tests/thread-claims.test.mjs:11:import { Broker } from '../src/broker.mjs';
packages/bus/tests/state.test.mjs:7:import { Broker, TASK_PAGE } from '../src/broker.mjs';
packages/bus/tests/access-revoke.test.mjs:14:import { Broker } from '../src/broker.mjs';
packages/bus/tests/plugins-verb.test.mjs:9:import { Broker } from '../src/broker.mjs';
packages/bus/tests/transport.test.mjs:8:import { Broker } from '../src/broker.mjs';
packages/bus/tests/publish.test.mjs:14:import { Broker } from '../src/broker.mjs';
packages/bus/tests/publish.test.mjs:221: const { ACTIONS, HARD_WAITS } = await import('../src/broker.mjs');
packages/bus/tests/tester-threads.test.mjs:11:import { Broker } from '../src/broker.mjs';
packages/bus/tests/tester-threads.test.mjs:284: // The row #specNotice (broker.mjs) writes for a human save of the spec in project stack. The save
packages/bus/tests/approval-binding.test.mjs:7:import { Broker } from '../src/broker.mjs';
packages/bus/tests/fire-revokes-grants.test.mjs:15:import { Broker, GRANT_EVIDENCE, registrySha256 } from '../src/broker.mjs';
packages/bus/tests/daily-quota-runtime.test.mjs:10:import { Broker } from '../src/broker.mjs';
packages/bus/tests/limit-checks.test.mjs:323: for (const file of ['../src/runtime.mjs', '../src/broker.mjs', '../src/process.mjs']) {
packages/bus/tests/spec-fixtures.mjs:6:import { Broker } from '../src/broker.mjs';
packages/bus/tests/decisions.test.mjs:9:import { Broker, RECENT } from '../src/broker.mjs';
packages/bus/tests/peer-transport.test.mjs:9:import { BusError } from '../src/broker.mjs';
packages/bus/tests/peer-transport.test.mjs:47: const {TASK_VERBS,BusError}=await import('../src/broker.mjs');
packages/bus/tests/messaging.test.mjs:8:import { Broker } from '../src/broker.mjs';
packages/bus/tests/rooms.test.mjs:8:import { Broker, THREAD_PAGE, THREAD_PAGE_MAX, TESTER_VERBS } from '../src/broker.mjs';
packages/bus/tests/rooms.test.mjs:80: assert.ok(!TESTER_VERBS.includes(verb));
packages/bus/tests/web.test.mjs:8:import { Broker, BusError, WEB_UNTRUSTED } from '../src/broker.mjs';
packages/bus/tests/replies.test.mjs:11:import { Broker, REPLIES } from '../src/broker.mjs';
packages/bus/tests/thread-shares.test.mjs:14:import { Broker } from '../src/broker.mjs';
packages/bus/tests/message-citation.test.mjs:6:import { Broker } from '../src/broker.mjs';
packages/bus/tests/limits.test.mjs:7:import {Broker} from '../src/broker.mjs';
packages/bus/tests/host-fixtures.mjs:6:import { registrySha256, writeGrantEvent } from '../src/broker.mjs';
packages/bus/tests/gitea-grant.test.mjs:12:import { Broker, GRANT_EVIDENCE, writeGrantEvent } from '../src/broker.mjs';
packages/bus/tests/csuite-messaging.test.mjs:12:import { Broker } from '../src/broker.mjs';
packages/bus/tests/decision-close.test.mjs:9:import { Broker } from '../src/broker.mjs';
packages/bus/tests/grant-scope.test.mjs:14:import { Broker, GRANT_EVIDENCE, writeGrantEvent } from '../src/broker.mjs';
packages/bus/tests/project-await.test.mjs:7:import { Broker, BusError } from '../src/broker.mjs';
packages/bus/tests/launcher-released.test.mjs:8:import { Broker } from '../src/broker.mjs';
packages/bus/tests/limit-stop.test.mjs:12:import { Broker } from '../src/broker.mjs';
packages/bus/tests/message-filter.test.mjs:12:import { Broker, REPLIES } from '../src/broker.mjs';
packages/bus/tests/land.test.mjs:18:import { Broker } from '../src/broker.mjs';
packages/bus/tests/single-use.test.mjs:7:import { Broker } from '../src/broker.mjs';
packages/bus/tests/session-clear.test.mjs:10:import { Broker } from '../src/broker.mjs';
packages/bus/tests/grant-migration.test.mjs:10:import { Broker } from '../src/broker.mjs';
packages/bus/tests/usage-broker.test.mjs:7:import { Broker } from '../src/broker.mjs';
packages/bus/tests/project-runtime.test.mjs:13: const source = readFileSync(new URL('../src/broker.mjs', import.meta.url), 'utf8');
packages/bus/tests/task-read.test.mjs:7:import { Broker } from '../src/broker.mjs';
packages/bus/tests/library.test.mjs:8:import { Broker } from '../src/broker.mjs';
packages/bus/tests/instances-verb.test.mjs:11:import { Broker, LIFECYCLE_HUMAN_VERBS, ORGANIZATION_KEYS, TESTER_ORGANIZATION_VERBS } from '../src/broker.mjs';
packages/bus/tests/routes-verb.test.mjs:13:import { Broker, ORGANIZATION_KEYS } from '../src/broker.mjs';
packages/bus/tests/broker.test.mjs:10:const { Broker } = await import('../src/broker.mjs').catch((e) => {
packages/bus/tests/grant-enroll.test.mjs:10:import { Broker, BusError, writeGrantEvent, registrySha256 } from '../src/broker.mjs';
packages/bus/tests/hard-waits.test.mjs:7:import { Broker, ACTIONS, HARD_WAITS } from '../src/broker.mjs';
packages/bus/tests/requirements.test.mjs:10:import { Broker } from '../src/broker.mjs';
packages/bus/tests/grant-executor.test.mjs:15:import { Broker, registrySha256, writeGrantEvent } from '../src/broker.mjs';
packages/bus/tests/sessions.test.mjs:10:import { Broker } from '../src/broker.mjs';
packages/bus/tests/user-principal.test.mjs:14: Broker, ACTIONS, USER_VERBS, TESTER_VERBS, TASK_VERBS, WEB_VERBS, HOST_VERBS, HOSTS_VERBS, CREDENTIAL_VERBS,
packages/bus/tests/user-principal.test.mjs:16:} from '../src/broker.mjs';
packages/bus/tests/user-principal.test.mjs:137: ...ACTIONS, ...TESTER_VERBS, ...TESTER_ORGANIZATION_VERBS, ...PROJECT_FILE_VERBS, ...LIBRARY_VERBS, ...CREDENTIAL_VERBS, ...HOSTS_VERBS,
packages/bus/tests/tester.test.mjs:10:import { Broker, TASK_VERBS } from '../src/broker.mjs';
packages/bus/tests/threads.test.mjs:9:import { Broker, THREAD_PAGE } from '../src/broker.mjs';
packages/webui/tests/routes-browser.test.mjs:15:import {BusError} from '../../bus/src/broker.mjs';
packages/bus/tests/creative.test.mjs:8:import { Broker } from '../src/broker.mjs';
packages/webui/tests/granted-user.test.mjs:16:import { Broker } from '../../bus/src/broker.mjs';
packages/bus/tests/task-page.test.mjs:7:import { Broker, TASK_PAGE, TASK_PAGE_MAX } from '../src/broker.mjs';
packages/webui/tests/user-option-swap.test.mjs:14:import { Broker } from '../../bus/src/broker.mjs';
packages/webui/tests/board-held.test.mjs:20:import { Broker } from '../../bus/src/broker.mjs';
packages/webui/tests/chat-sessions-access.test.mjs:10:import { Broker } from '../../bus/src/broker.mjs';
packages/bus/tests/review.test.mjs:14:import { Broker } from '../src/broker.mjs';
packages/webui/tests/sso-browser.test.mjs:14:import { Broker } from '../../bus/src/broker.mjs';
packages/bus/tests/revoke-window.test.mjs:13:import { Broker } from '../src/broker.mjs';
packages/webui/tests/bus.test.mjs:13:import { Broker } from '../../bus/src/broker.mjs';
packages/bus/tests/viewer.test.mjs:13: Broker, ACTIONS, USER_VERBS, VIEWER_VERBS, BOARD_VERBS, TESTER_VERBS, TESTER_ORGANIZATION_VERBS, PROJECT_FILE_VERBS,
packages/bus/tests/viewer.test.mjs:14:} from '../src/broker.mjs';
packages/bus/tests/viewer.test.mjs:116: const others = new Set([...ACTIONS, ...TESTER_VERBS, ...TESTER_ORGANIZATION_VERBS, ...PROJECT_FILE_VERBS, ...BOARD_VERBS, 'no.such.verb']);
packages/webui/tests/tester-fixture.mjs:12:import { Broker, BusError } from '../../bus/src/broker.mjs';
packages/webui/tests/routes.test.mjs:12:import { BusError } from '../../bus/src/broker.mjs';
packages/webui/tests/chat.test.mjs:191: const { Broker } = await import('../../bus/src/broker.mjs');
packages/webui/tests/bus-browser.test.mjs:11:import { Broker } from '../../bus/src/broker.mjs';
+122
View File
@@ -0,0 +1,122 @@
# Row 208 / 193d — Console shared threads and viewer reads
Base ec86f2c84, branch poe/row208; Gitea #1706. Approved design: e2d161e2e,
sha256 dd7fcb419ed8510e9d8d74b569183386d0942af8b68eecc0bbb5e182e677b523.
## Before
FED R6 supplies per-session user transport and the user-only page, row 192 SSO,
and row 206 supplies schema 3l shares and bus refusals. No bus/schema changes needed.
The webui channel adapter must forward the three existing share verbs.
## Choices and acceptance
Use the existing chat HTTP write fences and per-session transport for shares.
Expose only live collaborator grant project/instance scopes as UI hints, never
identity or bus authority. Re-read hints when rendering a user page; mixed grants
must hide writes on viewer-only threads even when another project allows writes.
Shared threads always read without session management, composer, resume or sharing.
Keep the exact governance notice on user thread/share pages. Targets are typed
owner/user principal names or *, avoiding a new user directory read API.
Add new isolated HTTP/broker and browser coverage. Remove each UI guard in mutants
and show the real broker still refuses viewer create, send, open and share writes.
Every test execution requires Sage CLEAR; use scratch TMPDIR, deadlines, npm ci,
and MOSAIC_REQUIRE_EDITOR_BUILD=1. No live state or network destinations.
## Tracking
Queue next from linked tree refused as designed; canonical next matched pinned brief.
Claim poe-0001 initially refused on Sage's queue lock; retry the same operation id.
Claim poe-0001 succeeded at canonical queue revision 3807 (in-progress).
During implementation, three string replacements briefly targeted canonical
packages/webui/src/public/{bus.js,user.js,chat.js} due to an incorrect command cwd.
Checked the complete diffs, surgically reversed exactly those replacements, and
applied them to this worktree. Sage independently confirmed canonical packages/
has no remaining diff (sage-3914). No tests or live actions occurred there.
poe-0002 requested the test slot. sage-3914 approved the exact serialized plan,
but CLEAR is pending Bentina returning her slot. No node/npm/browser commands
before CLEAR. New tests are thread-sharing.test.mjs with thread-sharing-fixture.mjs;
mutants.mjs runs four served-source mutants with assertion kills plus bus bypass.
## First verification (sage-3917 CLEAR)
Scratch logs: /mnt/storage/scratch/tmp/YU1G5B/.
Syntax 12/12 exit 0; npm-ci.log exit 0; editor-build.log exit 0;
thread-sharing.tap 7/7 exit 0. mutants.log and poe-208-mutants.json: four
absence-test assertion kills (exit 1 each), four bus bypass proofs (exit 0 each),
runner exit 0. Each mutant's individual log is poe-208-NAME-kill.tap or -bypass.tap.
Full serialized webui.tap stopped on an existing-test red: 44 pass, 1 fail before
termination; runner exit 1. Plugins not run. Slot returned to Sage via poe-0003.
Existing failure: chat-browser.test.mjs:123, uncertain writes/read failure draft
preservation, line 133 expects #chat-body after no-bus-host. The new current.thread
is not retained before list reads succeed, unlike old current.threads. A transient
read failure therefore hides the existing owner conversation. Proposed fix retains
same-thread metadata across refresh, never stale user grant scopes. No old tests
changed and no further execution pending Sage's ruling/CLEAR.
## Approved correction (sage-3918)
The old test is correct; this change broke its transient-failure draft contract.
Retain current.thread and shared only when the old id equals the requested id.
User chatGrants still start empty and are read fresh, so a stale hint is never
carried forward. A real 403/404 clears thread/shared metadata plus cached history,
drafts and operation forms. chat-browser.test.mjs remains byte-identical.
Fix applied statically; Sage will CLEAR the four mutants, full webui and plugins
suites after Bentina returns the slot. No node/npm/browser commands before then.
Clarification during static review: retain the existing distinction between a
thread-level refusal and a local bad-session selector (old row 272 B1 test).
All 403/404 responses clear visible thread/shared metadata, as before; thread-level
thread-not-owned/thread-not-found/access-refused also erase sibling cached history
and drafts. A bad-session selector never erases valid sibling drafts/recovery keys.
This preserves the existing invalidation contract rather than broadening the fix.
sage-3920 CLEAR: first mutant rerun stopped on a new-test failure, not a valid
mutant kill. /mnt/storage/scratch/tmp/8UlAhS/poe-208-composer-kill.tap shows null
old.thread on initial /chat: old?.id and undefined route id compared equal.
Fixed by requiring old to exist before either same-id metadata carry. This is a
new defect in the approved correction, no old test change. Rerunning the plan.
## Second slot outcome (sage-3920)
Scratch /mnt/storage/scratch/tmp/8UlAhS/.
Initial mutants.log exit 1 was the new null-previous-state defect above. Fixed;
mutants-r2.log exit 0: all four assertion kills exit 1, all four bypass proofs
exit 0 (individual logs overwritten by the successful rerun; summary JSON kept).
Full serialized webui.tap stopped on old tests: 114 top-level pass, 5 fail,
runner exit 1. The original draft-preservation failure and row 272 B1 both pass.
The new old-test failure is environmental: decide-sso.test.mjs cannot import
root dependency oauth4webapi (ERR_MODULE_NOT_FOUND); all five failures share it.
packages/webui npm ci installs its editor tooling, not root runtime dependencies.
No tests changed; plugins not run. Request root npm ci from pinned lockfile in
next slot before rerunning webui and plugins. Slot returned via poe-0004.
sage-3924 ruled the second red an environment gap, not a product defect. Approved
root npm ci --ignore-scripts from the pinned lock, full webui then plugins,
serialized. sage-3925 CLEAR starts that sequence. Push only after green; send
base/tip and every changed path's SHA-256 to Sage and Gladstone.
## Final builder verification (sage-3925 CLEAR)
Root npm ci --ignore-scripts exit 0 from pinned lock.
Full webui: exit 0, 418 pass, 0 fail, 0 skipped (271964 ms).
Plugins: exit 0, 119 pass, 0 fail, 0 skipped (823 ms).
Logs /mnt/storage/scratch/tmp/Et9xAa/{root-npm-ci.log,webui.tap,plugins.tap}.
Slot returned immediately after plugins via poe-0005. All new acceptance cases
are included in the full webui run. Four latest UI mutants and bus bypass proofs
remain green at /mnt/storage/scratch/tmp/8UlAhS/mutants-r2.log and
poe-208-mutants.json. No old tests changed. No bus authority/schema changes.
Acceptance: owner and collaborator can share/unshare own writable threads;
shared lists preserve the plain-list shape and show owner names safely as text;
shared threads cannot compose, resume, mutate sessions or reshare. Viewer old
own threads retain read history with no write controls. Mixed grants and live
downgrades hide writes only where coverage requires; revoked shares clear the
view. Direct browser POSTs bypass all UI checks and the real broker still
refuses viewer create/send/open/share. Existing exact governance notice retained.
Global scripts/test-*.sh gate and independent reviewer approval remain Sage's
landing gate; this builder does not claim those or any live deployment.
+28
View File
@@ -0,0 +1,28 @@
// Run only under Sage's CLEAR, with scratch TMPDIR and MOSAIC_REQUIRE_EDITOR_BUILD=1.
// Mutants alter only the fixture-served copy of chat.js; repository source is untouched.
import { spawnSync } from 'node:child_process';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
const file = 'packages/webui/tests/thread-sharing.test.mjs';
const results = [];
for (const mutant of ['composer', 'open', 'share', 'create']) {
for (const [stage, pattern, expected] of [
['kill', '193d viewer UI has no', 1],
['bypass', '193d bypassed UI checks', 0],
]) {
const run = spawnSync(process.execPath, ['--test', '--test-concurrency=1', '--test-name-pattern=' + pattern, file], {
env: { ...process.env, POE_UI_MUTANT: mutant }, encoding: 'utf8', timeout: 90000,
});
const log = join(process.env.TMPDIR, `poe-208-${mutant}-${stage}.tap`);
writeFileSync(log, (run.stdout ?? '') + (run.stderr ?? ''));
const killedByAssertion = /ERR_ASSERTION/.test(run.stdout ?? '');
const passed = run.status === expected && (stage !== 'kill' || killedByAssertion);
results.push({ mutant, stage, exit: run.status, signal: run.signal, passed, log });
console.log(JSON.stringify(results.at(-1)));
if (!passed) {
writeFileSync(join(process.env.TMPDIR, 'poe-208-mutants.json'), JSON.stringify(results, null, 2));
process.exit(1);
}
}
}
writeFileSync(join(process.env.TMPDIR, 'poe-208-mutants.json'), JSON.stringify(results, null, 2));
+2 -2
View File
@@ -1620,5 +1620,5 @@ are never rewritten or removed; corrections are new entries.
2026-10-24T00:41Z | Duckworth (Opus 5.5) | row 323 round 1 static review on 6a0f0f4f8 (sage-3865, sage-3870) | APPROVE (static) C1 Sage full gate cli, manifest 903ef022 matches, fields sourced, tests read shipped bytes, tester mutant real, N1-N2 doc (duckworth-0300) |
2026-10-24T01:13Z | Goldie (a13dd6d5-5e73-4b7d-8318-02abeff24940) | Row325 / #1821 Case1 project key | New goldie/row325 worktree at99930362e; pinned brief60d78e44e. Draft JSON key correction, new real-project-byte tests, sole old line70 key edit under mos-1564. Static checks only; requesting Sage CLEAR, no queue writes.
2026-10-24T01:14Z | Goldie (a13dd6d5-5e73-4b7d-8318-02abeff24940) | Row325 / #1821 verified | sage-3882: new3/3, business264/264, one key mutant killed/restored, new3/3 again, per-run fresh shortTMP/load/helperCLEAR. Four manifest entries verify; sole old assertion key change, no queue writes. Candidate push/report to Sage and Gladstone next.
2026-10-24T04:27Z | Goldie (a13dd6d5-5e73-4b7d-8318-02abeff24940) | Row265 / #1770 C2-09b rooms | Goldie owns build, Boyd reviews under sage-3915. New goldie/row265 worktree at ec86f2c84, claim rev3814; room verbs/parser/new tests prepared, no old tests changed. Sage-3932 slot: new parser14/14 and rooms18/18 passed; whole bus in progress before refusal mutants, stop rule active.
2026-10-24T04:35Z | Goldie (a13dd6d5-5e73-4b7d-8318-02abeff24940) | Row265 / #1770 verified | First slot stopped at project-runtime import pin. mos-1567 narrowly authorizes pin and pure-source mount correction. sage-3942 rerun: sandbox10/10, parser15/15, rooms18/18, bus957/957, twelve refusal mutants killed/restored SHA71b32ee4, parser15/15 + rooms18/18 again. Slot returned goldie-0034; no further test runs. Boyd reviews RUNNER_FILES alongside rooms; own-branch push/claimant in-review next.
2026-10-24 | Poe | Q246 / mos-1566, row 208 / 193d (#1706), poe/row208 | Started; approved design hash verified; implementation and isolated acceptance pending.
2026-10-24 | Poe | Row 208 / 193d (#1706), poe/row208 | Builder acceptance green: webui 418/418, plugins 119/119, four UI mutants killed with bus bypass proofs; candidate prepared for Gladstone then Duckworth guard review.
-3
View File
@@ -242,9 +242,6 @@ a receipt with empty `changes`. A refusal is `action.refused` with its code alon
| `replies` | `{id}`: a message UUID; human and reader sessions only. The messages whose `in_reply_to` is that id, oldest first, at most 50. Writes nothing. |
| `project.create` | `{name,repo?}`; human sessions only (row 121, #1615). `repo` must name one of the business's `projects` (`unknown-repo`); a second project with the same name refuses `project-exists`. Returns `{id}`. |
| `project.list` | No args; human sessions only. The business's projects, oldest first. |
| `room.open` | `{project}`; human only. Creates/reuses the caller's canonical member threads, named by sorted `THREAD_DEFINITIONS` instance keys. A taken name/owner/instance refuses `room-thread-name-taken` atomically. Returns `{project,threads:[{id,instance,name}]}`. |
| `room.send` | `{project,body}`; human only, room must be fully open (`room-not-open`). Leading whitespace and `@name` tokens (including a final EOF tag) address current display names or member keys; no tags broadcasts, repeats send once. Unknown/ambiguous members refuse `unknown-addressee`/`addressee-ambiguous`; tags-only refuses `invalid-request`. Stores the original body, one normal message/route/human.input per target in one transaction, with one shared message timestamp. Returns `{ids,to}` in member-key order. |
| `room.read` | `{project,after?,limit?}`; human only, complete owned room. Oldest first across all sessions of member threads. Returns entries with first-message fields plus `thread`, `session`, `instance`, `ids` and `to`. Only consecutive human rows with equal at/body/class and distinct instances merge; repeated instances split identical sends. `limit` counts merged entries (default 50, 1–200). Continue after the last entry's last `ids` element; an `after` outside the room refuses `message-not-found`. No tester access or schema change. |
| `thread.create` | `{project,name}`; human sessions only. The thread belongs to the caller and to the business's one thread instance (Projects and threads, below). Returns `{id,instance}`. Refuses `project-not-found`, `thread-exists`, `thread-instance-missing` or `thread-instance-ambiguous`. |
| `thread.list` | `{project?,shared?}`; human sessions only. The caller's own threads, oldest first. With `shared: true` (row 206), the threads others own that the caller may read (Thread shares, below), each row adding `owner`. |
| `thread.open` | `{thread,session?}`; human sessions only. Records a `thread_opens` row, which asks the launcher to start the thread's session. Returns `{opened}`. |
-14
View File
@@ -1,14 +0,0 @@
// C2-09b: a pure leading-address parser, shared with the room UI's parity test.
// The remainder is for empty-body checks; the broker always stores the original input.
export function parseAddress(input) {
if (typeof input !== 'string') throw new TypeError('body must be a string');
const tags = [];
let body = input.trimStart();
while (true) {
const token = /^@(\S+)(?:\s+|$)/u.exec(body);
if (!token) break;
tags.push(token[1]);
body = body.slice(token[0].length);
}
return { tags, body };
}
-94
View File
@@ -1,5 +1,4 @@
import { randomUUID, randomBytes, createHash } from 'node:crypto';
import { parseAddress } from './address.mjs';
import { isAbsolute, dirname } from 'node:path';
import { initializeControl, processingStatus } from '../../processing-control/src/state.mjs';
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
@@ -6419,99 +6418,6 @@ export class Broker {
.all('SELECT id,at,name,repo FROM projects WHERE business=? ORDER BY seq', s.business)
.filter((p) => !s.user || this.#granted(s, 'project.list', p.id, null));
}
// C2-09b: canonical per-instance threads form one human-owned project room.
case 'room.open':
case 'room.send':
case 'room.read': {
this.#human(s);
const opening = verb === 'room.open';
const sending = verb === 'room.send';
keys(a, opening ? ['project'] : sending ? ['project', 'body'] : ['project', 'after', 'limit'],
sending ? ['project', 'body'] : ['project']);
const project = identifier(a.project);
if (!this.#store.get('SELECT 1 FROM projects WHERE id=? AND business=?', project, s.business)) fail('project-not-found');
const members = Object.keys(b.roles).filter((instance) => this.#threaded(s.business, instance)).sort();
if (!members.length) fail('thread-instance-missing');
const owner = this.#owner(s);
// Preflight all collisions/missing threads before open creates or send writes anything.
const threads = members.map((instance) => {
const t = this.#store.get('SELECT * FROM threads WHERE project=? AND name=?', project, instance);
if (t && (t.instance !== instance || t.owner !== owner))
fail(opening ? 'room-thread-name-taken' : 'room-not-open');
if (!t && !opening) fail('room-not-open');
return t ?? { instance, name: instance };
});
if (opening) {
return { project, threads: threads.map((t) => ({
id: t.id ?? this.#dispatch(s, 'thread.create', { project, name: t.name, instance: t.instance }).id,
instance: t.instance, name: t.name,
})) };
}
if (sending) {
string(a.body, 32768);
const addressed = parseAddress(a.body);
if (!addressed.body.trim()) fail('invalid-request');
const selected = new Set();
for (const tag of addressed.tags) {
const candidates = new Set([this.#named(s.business, tag), tag].filter((instance) => members.includes(instance)));
if (!candidates.size) fail('unknown-addressee');
if (candidates.size > 1) fail('addressee-ambiguous');
selected.add([...candidates][0]);
}
const targets = threads.filter((t) => !addressed.tags.length || selected.has(t.instance));
const sessions = targets.map((t) => {
const session = this.#defaultSession(t.id);
this.#sessionFence(session);
this.#checkMessaging(s.business, 'human', t.instance);
return session;
});
const at = this.#now();
const ids = targets.map((t, i) => {
const mid = randomUUID();
this.#store.run(
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
mid, at, s.business, 'human', 'human-cli', t.instance, 'INFO', null, null, null, a.body,
);
this.#route(s.business, mid, t.instance, sessions[i]);
this.#event(s, 'human.input', { kind: 'instruction', message: mid }, mid);
return mid;
});
return { ids, to: targets.map((t) => t.instance) };
}
const limit = a.limit ?? THREAD_PAGE;
if (!Number.isInteger(limit) || limit < 1 || limit > THREAD_PAGE_MAX) fail('invalid-request');
const tids = threads.map((t) => t.id);
const placeholders = tids.map(() => '?').join(',');
let after = 0;
if (a.after !== undefined) {
after = this.#store.get(
`SELECT m.seq FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE m.id=? AND m.business=? AND mt.thread IN (${placeholders})`,
identifier(a.after), s.business, ...tids,
)?.seq;
if (after === undefined) fail('message-not-found');
}
// At most one row per instance joins a group; one lookahead row completes the last page.
const rows = this.#store.all(
`SELECT m.*,mt.thread,mt.session,t.instance FROM messages m JOIN message_threads mt ON mt.message=m.id JOIN threads t ON t.id=mt.thread
WHERE m.business=? AND mt.thread IN (${placeholders}) AND m.seq>? ORDER BY m.seq LIMIT ${limit * members.length + 1}`,
s.business, ...tids, after,
);
const entries = [];
let previous;
for (const m of rows) {
const entry = entries.at(-1);
if (entry && m.from_role === 'human' && previous.from_role === 'human' && m.seq === previous.seq + 1
&& m.at === previous.at && m.body === previous.body && m.class === previous.class && !entry.to.includes(m.instance)) {
entry.ids.push(m.id);
entry.to.push(m.instance);
} else {
if (entries.length === limit) break;
entries.push({ ...m, ids: [m.id], to: [m.instance] });
}
previous = m;
}
return entries;
}
// A thread belongs to the user who made it and to one declared thread instance.
// Row 177 (#1675): a tester's thread is owned by `tester`, so neither reaches the other's.
case 'thread.create': {
-1
View File
@@ -3,4 +3,3 @@ export { startBroker } from './runtime.mjs';
export { Client } from './client.mjs';
export { views } from './views.mjs';
export { busBusiness } from './business.mjs';
export { parseAddress } from './address.mjs';
-16
View File
@@ -1,16 +0,0 @@
// C2-09b / C2-10c: reusable parser vectors. Keep the expected result independent of the parser.
export const addressCases = [
['one', '@continuity x', { tags: ['continuity'], body: 'x' }],
['many', '@bean @chet hello', { tags: ['bean', 'chet'], body: 'hello' }],
['repeat', '@bean @bean hi', { tags: ['bean', 'bean'], body: 'hi' }],
['plain', 'hello @bean', { tags: [], body: 'hello @bean' }],
['stop', '@bean hello @chet', { tags: ['bean'], body: 'hello @chet' }],
['EOF tag', '@continuity', { tags: ['continuity'], body: '' }],
['EOF last tag', '@bean @chet', { tags: ['bean', 'chet'], body: '' }],
['leading whitespace', ' \t\n@bean\t@chet\nhello ', { tags: ['bean', 'chet'], body: 'hello ' }],
['tags only', '@bean \t\n', { tags: ['bean'], body: '' }],
['empty', '', { tags: [], body: '' }],
['whitespace', ' \t', { tags: [], body: '' }],
['bare at', '@ hello', { tags: [], body: '@ hello' }],
['punctuation is a name', '@unknown, hi', { tags: ['unknown,'], body: 'hi' }],
];
-17
View File
@@ -1,17 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { parseAddress } from '../src/address.mjs';
import { addressCases } from './address-cases.mjs';
for (const [label, input, expected] of addressCases) {
test(`parseAddress: ${label}`, () => assert.deepEqual(parseAddress(input), expected));
}
test('parseAddress rejects non-text without coercion', () => {
for (const input of [null, undefined, {}, 1]) assert.throws(() => parseAddress(input), TypeError);
});
// mos-1567: new coverage, separate from the pre-existing broker import pin.
test('the mounted address parser has no import dependency', () => {
assert.doesNotMatch(readFileSync(new URL('../src/address.mjs', import.meta.url), 'utf8'), /\bimport\b/);
});
@@ -14,8 +14,6 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
assert.deepEqual([...source.matchAll(/^import .*;$/gm)].map(m => m[0]), [
// Row 210 (#1707): createHash gives host.run events the argv, stdin and output sha256 values.
"import { randomUUID, randomBytes, createHash } from 'node:crypto';",
// Row 265 (#1770): pure room address parsing, mounted in RUNNER_FILES beside the broker.
"import { parseAddress } from './address.mjs';",
// Row 65 (#1558): bindLaunch checks a run's transcript path is absolute.
"import { isAbsolute, dirname } from 'node:path';",
// Row 191 / sage-1367: the one additional read-only CODE source, with no project/git imports.
-247
View File
@@ -1,247 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../src/store.mjs';
import { Broker, THREAD_PAGE, THREAD_PAGE_MAX, TESTER_VERBS } from '../src/broker.mjs';
const MEMBERS = ['bean', 'chet', 'continuity'];
const shipped = (name) => JSON.parse(readFileSync(new URL(`../../../roles/${name}.json`, import.meta.url), 'utf8'));
function setup(t, { deny = false, empty = false } = {}) {
const root = mkdtempSync(join(tmpdir(), 'bus-rooms-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const role = (definition) => ({ definition, authority: { withinRole: ['message.send'], crossRole: [] } });
// Deliberately unsorted, with two instances of one definition and a non-member chief.
const roles = { continuity: role('continuity'), chet: role('companion'), bean: role('companion'), chief: role('chief-of-staff') };
const definitions = {
'chief-of-staff': {}, companion: { messaging: shipped('companion').messaging },
continuity: { messaging: deny ? { sendTo: ['human'], receiveFrom: [] } : shipped('continuity').messaging },
};
const b = new Broker({ store, businesses: { demo: {
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
roles: empty ? { chief: roles.chief } : roles, definitions,
projects: { stack: { root: '/nonexistent' } }, vars: { 'bus.tester': true },
} } });
const caps = {
human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
tester: b.bindTester({ business: 'demo' }),
};
const call = (verb, args = {}, who = 'human') => b.request(caps[who], { verb, args });
caps.chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
call('role.claim', {}, 'chief');
const project = call('project.create', { name: 'room', repo: 'stack' }).id;
const open = () => call('room.open', { project });
const send = (body, extra = {}) => call('room.send', { project, body, ...extra });
const read = (extra = {}) => call('room.read', { project, ...extra });
const effects = () => ['messages', 'message_threads', 'deliveries'].map((table) => store.get(`SELECT count(*) n FROM ${table}`).n)
.concat(store.get("SELECT count(*) n FROM events WHERE kind='human.input'").n);
const refuse = (code, fn) => {
const before = effects();
assert.throws(fn, { code });
assert.deepEqual(effects(), before, `${code}: no message, route, delivery or human.input`);
};
return { b, caps, store, call, project, open, send, read, effects, refuse };
}
test('room.open sorts all threaded instances, creates defaults, and reuses caller-owned threads', (t) => {
const { open, call, project, store } = setup(t);
const existing = call('thread.create', { project, name: 'chet', instance: 'chet' }).id;
const result = open();
assert.equal(result.project, project);
assert.deepEqual(result.threads.map(({ instance, name }) => [instance, name]), MEMBERS.map((x) => [x, x]));
assert.equal(result.threads[1].id, existing);
for (const thread of result.threads) {
assert.equal(store.get('SELECT owner FROM threads WHERE id=?', thread.id).owner, 'jason');
assert.equal(call('session.list', { thread: thread.id }).filter((s) => s.default).length, 1);
}
assert.deepEqual(open(), result);
assert.equal(store.get('SELECT count(*) n FROM threads').n, 3);
});
for (const collision of ['instance', 'owner']) {
test(`room.open preflights ${collision} collisions atomically`, (t) => {
const { call, project, open, store } = setup(t);
call('thread.create', { project, name: 'continuity', instance: collision === 'instance' ? 'bean' : 'continuity' },
collision === 'owner' ? 'tester' : 'human');
assert.throws(open, { code: 'room-thread-name-taken' });
assert.deepEqual(store.all('SELECT name FROM threads').map((r) => r.name), ['continuity']);
assert.equal(store.get('SELECT count(*) n FROM sessions').n, 1);
});
}
test('room verbs require the human, and none is a tester verb', (t) => {
const { call, project, open, refuse } = setup(t);
open();
for (const verb of ['room.open', 'room.send', 'room.read']) {
const args = { project, ...(verb === 'room.send' ? { body: 'hello' } : {}) };
assert.ok(!TESTER_VERBS.includes(verb));
refuse('tester-refused', () => call(verb, args, 'tester'));
refuse('human-required', () => call(verb, args, 'chief'));
}
});
test('room verbs refuse missing projects and zero member businesses', (t) => {
const { call, refuse } = setup(t);
for (const verb of ['room.open', 'room.send', 'room.read']) {
refuse('project-not-found', () => call(verb, { project: randomUUID(), ...(verb === 'room.send' ? { body: 'hello' } : {}) }));
}
const empty = setup(t, { empty: true });
assert.throws(empty.open, { code: 'thread-instance-missing' });
});
for (const state of ['missing', 'foreign-owner', 'wrong-instance']) {
test(`send/read refuse a ${state} canonical member without effects`, (t) => {
const { call, project, send, read, refuse } = setup(t);
for (const instance of MEMBERS) {
if (instance === 'continuity' && state === 'missing') continue;
call('thread.create', { project, name: instance, instance: instance === 'continuity' && state === 'wrong-instance' ? 'bean' : instance },
instance === 'continuity' && state === 'foreign-owner' ? 'tester' : 'human');
}
refuse('room-not-open', () => send('hello'));
refuse('room-not-open', read);
});
}
test('room.send targets a key, broadcasts without tags, and preserves original text and ordinary route/input rows', (t) => {
const { open, send, store, read } = setup(t);
const threads = open().threads;
const one = send('@continuity x');
assert.deepEqual(one.to, ['continuity']);
assert.equal(one.ids.length, 1);
assert.equal(store.get('SELECT thread FROM message_threads WHERE message=?', one.ids[0]).thread, threads[2].id);
const body = ' \t@bean @chet hello @continuity';
const two = send(body);
assert.deepEqual(two.to, ['bean', 'chet']);
const all = send('hello @bean');
assert.deepEqual(all.to, MEMBERS);
const rows = store.all('SELECT * FROM messages ORDER BY seq');
assert.deepEqual(rows.map((r) => r.body), ['@continuity x', body, body, ...MEMBERS.map(() => 'hello @bean')]);
assert.equal(new Set(rows.slice(-3).map((r) => r.at)).size, 1);
assert.ok(rows.every((r) => r.from_role === 'human' && r.from_run === 'human-cli' && r.class === 'INFO'));
assert.equal(store.get('SELECT count(*) n FROM message_threads').n, 6);
assert.equal(store.get("SELECT count(*) n FROM deliveries WHERE op='routed'").n, 6);
assert.equal(store.get("SELECT count(*) n FROM events WHERE kind='human.input'").n, 6);
assert.deepEqual(read().map((r) => r.to), [['continuity'], ['bean', 'chet'], MEMBERS]);
});
test('current display names and repeated tags resolve once; a replaced display name is unknown', (t) => {
const { open, call, send, refuse } = setup(t);
open();
call('name.set', { instance: 'bean', name: 'beanie' });
assert.deepEqual(send('@beanie @bean @beanie hi').to, ['bean']);
call('name.set', { instance: 'bean', name: 'newbeanie' });
refuse('unknown-addressee', () => send('@beanie hi'));
assert.deepEqual(send('@newbeanie hi').to, ['bean']);
});
test('a legacy name colliding with another member key is ambiguous; its own key resolves once', (t) => {
const { open, send, store, refuse } = setup(t);
open();
// Naming policy now forbids key collisions; legacy data can still contain them. Append real rows.
store.run('INSERT INTO instance_names(at,business,instance,name,by) VALUES(?,?,?,?,?)', new Date().toISOString(), 'demo', 'bean', 'chet', 'fixture');
refuse('addressee-ambiguous', () => send('@chet hi'));
store.run('INSERT INTO instance_names(at,business,instance,name,by) VALUES(?,?,?,?,?)', new Date().toISOString(), 'demo', 'bean', 'bean', 'fixture');
assert.deepEqual(send('@bean @bean hi').to, ['bean']);
});
test('unknown or non-member addressees and tags-only bodies leave no message effects', (t) => {
const { open, send, refuse } = setup(t);
open();
for (const body of ['@nobody x', '@chief x', '@bean @nobody x']) refuse('unknown-addressee', () => send(body));
for (const body of ['@continuity', ' \t@bean\n@chet ', '', ' \t']) refuse('invalid-request', () => send(body));
});
test('messaging denial on the last target rolls back the entire broadcast; allowed subset still works', (t) => {
const { open, send, refuse } = setup(t, { deny: true });
open();
refuse('message-sender-refused', () => send('all'));
assert.deepEqual(send('@bean @chet okay').to, ['bean', 'chet']);
});
test('a clearing last member fences the whole send; another active target still accepts', (t) => {
const { b, caps, open, call, send, refuse } = setup(t);
const thread = open().threads[2].id;
caps.worker = b.bindLaunch({ business: 'demo', role: 'continuity', run: 'continuity-run', harness: 'pi', thread });
call('role.claim', {}, 'worker');
const session = call('session.list', { thread })[0].id;
call('session.clear', { thread, session, revision: 0, op: 'room-clear', confirm: true });
refuse('session-clearing', () => send('all'));
assert.deepEqual(send('@bean okay').to, ['bean']);
});
test('room.read groups one send, pages by the last grouped id, and rejects foreign or absent cursors', (t) => {
const { open, send, read, call, project, refuse } = setup(t);
const threads = open().threads;
const first = send('first');
const second = send('@continuity second');
const page = read({ limit: 1 });
assert.equal(page.length, 1);
assert.deepEqual(page[0].ids, first.ids);
assert.deepEqual(page[0].to, MEMBERS);
assert.equal(page[0].thread, threads[0].id);
assert.equal(page[0].instance, 'bean');
assert.deepEqual(read({ after: first.ids.at(-1), limit: 1 }).map((r) => r.ids), [second.ids]);
assert.deepEqual(read({ after: second.ids[0] }), []);
const other = call('project.create', { name: 'other', repo: 'stack' }).id;
const thread = call('thread.create', { project: other, name: 'bean', instance: 'bean' }).id;
const foreign = call('thread.send', { thread, body: 'elsewhere' }).id;
const noncanonical = call('thread.create', { project, name: 'side', instance: 'bean' }).id;
const side = call('thread.send', { thread: noncanonical, body: 'side' }).id;
for (const after of [foreign, side, randomUUID()]) refuse('message-not-found', () => read({ after }));
for (const limit of [0, -1, 1.1, THREAD_PAGE_MAX + 1, '1']) refuse('invalid-request', () => read({ limit }));
});
test('room.read keeps identical sends at the same timestamp separate and includes every session', (t) => {
const { open, call, project, store, read } = setup(t);
const threads = open().threads;
const at = '2026-10-24T00:00:00.000Z';
const batches = [[], []];
// Broker clocks are monotonic. Append two independently valid transaction-shaped batches to
// exercise the historical same-at boundary without altering production clocks or existing rows.
for (const batch of batches) for (const thread of threads) {
const id = randomUUID();
batch.push(id);
const session = call('session.list', { thread: thread.id })[0].id;
store.run('INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES(?,?,?,?,?,?,?,?)',
id, at, 'demo', 'human', 'human-cli', thread.instance, 'INFO', 'same');
store.run('INSERT INTO message_threads(message,thread,session) VALUES(?,?,?)', id, thread.id, session);
}
assert.deepEqual(read().map((r) => r.ids), batches);
assert.deepEqual(read({ limit: 1 })[0].ids, batches[0]);
assert.deepEqual(read({ after: batches[0].at(-1) }).map((r) => r.ids), [batches[1]]);
const created = call('session.create', { thread: threads[0].id, name: 'later', op: 'new-later' });
call('thread.send', { thread: threads[0].id, session: created.id, body: 'later session' });
assert.equal(read().at(-1).body, 'later session');
assert.equal(read().at(-1).session, created.id);
assert.equal(read().at(-1).thread, threads[0].id);
assert.equal(store.get('SELECT count(*) n FROM threads WHERE project=?', project).n, 3);
});
test('room.read defaults and caps the number of merged entries, not recipient rows', (t) => {
const { open, send, read } = setup(t);
open();
for (let i = 0; i <= THREAD_PAGE_MAX; i++) send(`message ${i}`);
assert.equal(read().length, THREAD_PAGE);
assert.equal(read({ limit: THREAD_PAGE_MAX }).length, THREAD_PAGE_MAX);
assert.ok(read({ limit: 1 })[0].to.length === 3);
});
test('room.read includes an agent reply with its thread and instance without merging it with human rows', (t) => {
const { b, caps, open, call, send, read, refuse, project } = setup(t);
const thread = open().threads[0].id;
send('question');
caps.worker = b.bindLaunch({ business: 'demo', role: 'bean', run: 'bean-run', harness: 'pi', thread });
call('role.claim', {}, 'worker');
const reply = call('message.send', { to: 'human', body: 'answer' }, 'worker');
const entries = read();
assert.equal(entries.length, 2);
assert.deepEqual(entries[0].to, MEMBERS);
assert.equal(entries[1].id, reply.id);
assert.equal(entries[1].from_role, 'bean');
assert.equal(entries[1].thread, thread);
assert.equal(entries[1].instance, 'bean');
refuse('human-required', () => call('room.read', { project }, 'worker'));
});
+3
View File
@@ -9,6 +9,9 @@ const VERBS = new Set([
"thread.messages",
"thread.send",
"thread.open",
"thread.shares",
"thread.share",
"thread.unshare",
"session.list",
"session.create",
"session.clear",
-1
View File
@@ -57,7 +57,6 @@ export const RUNNER_FILES = Object.freeze([
"packages/harness/src/pi-extension.mjs",
"packages/bus/src/client.mjs",
"packages/bus/src/broker.mjs",
"packages/bus/src/address.mjs",
"packages/bus/src/landing.mjs",
"packages/business/src/vocabulary.mjs",
"packages/plugins/src/kinds.mjs",
+13
View File
@@ -1091,3 +1091,16 @@ During Q175's read-only interim both buttons show as unavailable and point to
`mosaic halt` / `mosaic resume` in the instance CLI. There is no halt-only send
exception or alternate authentication path. Landing the UI and activating it
remain separately gated.
Row 208 (193d): Chat includes a **Shared with me** list. Shared threads are
read-only, including for the business owner. On your own writable threads,
**Share read-only** accepts `user:ID`, the business owner's name, or `*` for
everyone with coverage; **Unshare** ends that target's share. A share grants
no project or instance access of its own.
Viewers can read shared threads and their old own threads. Their pages omit
creation, composer, resume, share and session mutation controls. Mixed grants
apply separately to each project and instance; a collaborator grant elsewhere
does not enable writes here. Grant hints refresh on each user chat render and
background refresh, while the bus decides every operation from live grants.
The existing user notice names the governance records visible to the owner.
+10
View File
@@ -18,6 +18,16 @@ export function consoleAccess({ load, business }) {
} catch { return null; }
};
return Object.freeze({
// UI hints only: no grants belonging to another user, receipts or subjects escape.
chatGrants: session => {
try {
if (lookup(session.provider, session.sub) !== session.user) return [];
const doc = load(), b = business();
return doc.grants.filter(g => g.user === session.user && g.business === b &&
covers(g, { business: b, project: g.projects[0], instance: g.instances[0] }, new Date(), { level: 'collaborator' }))
.map(g => ({ projects: [...g.projects], instances: [...g.instances] }));
} catch { return []; }
},
// The user id this (provider, sub) signs in as, or null.
find: ({ provider, sub }) => lookup(provider, sub),
// Is this session's user still the holder of its own subject, with a live grant?
+17 -3
View File
@@ -22,6 +22,7 @@ const STATUS = {
'unknown-verb': 503, 'channel-operation': 503,
'tester-off': 403, 'tester-required': 403, 'tester-refused': 403,
// FED R6 (#1800): the broker's refusals of a granted user.
'share-not-covered': 403, 'share-exists': 409, 'share-not-found': 404,
'access-refused': 403, 'access-revoked': 401,
};
function safeMessage(code) {
@@ -72,7 +73,7 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site');
}
try {
const match = /^\/api\/chat\/threads\/([^/]+)\/(messages|open|sessions|clear|cancel|operation)$/.exec(path);
const match = /^\/api\/chat\/threads\/([^/]+)\/(messages|open|sessions|clear|cancel|operation|shares|share|unshare)$/.exec(path);
if (match) {
thread = match[1];
if (!id(thread)) throw Error();
@@ -89,6 +90,9 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev
if (!exact(v, ['project', 'name', 'instance'], ['project', 'name']) || !id(v.project) || !name(v.name) ||
(Object.hasOwn(v, 'instance') && (typeof v.instance !== 'string' || !KEY.test(v.instance)))) throw Error();
action = 'createThread'; args = v;
} else if (['share', 'unshare'].includes(match?.[2])) {
if (!exact(v, ['to']) || typeof v.to !== 'string' || !v.to.length || v.to.length > 200 || /[\r\n\0]/.test(v.to)) throw Error();
action = match[2]; args = { thread, to: v.to };
} else if (match?.[2] === 'sessions') {
if (!exact(v, ['name', 'op']) || !name(v.name) || !KEY.test(v.op ?? '')) throw Error();
action = 'createSession'; args = { thread, name: v.name, op: v.op };
@@ -109,9 +113,12 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev
} else if (path === '/api/chat/projects') {
query(search, []); action = 'projects'; args = {};
} else if (path === '/api/chat/threads') {
const q = query(search, ['project']);
const q = query(search, ['project', 'shared']);
if (q.has('shared') && q.get('shared') !== 'true') throw Error();
if (q.has('project') && !id(q.get('project'))) throw Error();
action = 'threads'; args = q.has('project') ? { project: q.get('project') } : {};
action = 'threads'; args = { ...(q.has('project') ? { project: q.get('project') } : {}), ...(q.has('shared') ? { shared: true } : {}) };
} else if (match?.[2] === 'shares') {
query(search, []); action = 'shares'; args = { thread };
} else if (match?.[2] === 'sessions') {
query(search, []); action = 'sessions'; args = { thread };
} else if (match?.[2] === 'operation') {
@@ -155,6 +162,13 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev
} : {};
return json(res, 200, { rows: value, ...page, at: new Date().toISOString() });
}
if (action === 'share' || action === 'unshare') {
if (value?.thread !== thread || value?.to !== args.to || value?.op !== action) {
line('error=outcome-unknown'); return refuse(503, 'outcome-unknown', 'unknown');
}
line(`thread=${thread} op=${action}`);
return json(res, 200, { thread, to: value.to, op: value.op });
}
if (!value || (action === 'open' ? value.opened !== thread : !id(value.id)) ||
(action === 'createThread' && (typeof value.instance !== 'string' || !KEY.test(value.instance)))) {
line('error=outcome-unknown');
+4 -1
View File
@@ -24,7 +24,10 @@ export function createChat({ call, owner = null }) {
return Object.freeze({
projects: () => channel('project.list', {}),
createProject: ({ name, repo }) => channel('project.create', { name, ...(repo === undefined ? {} : { repo }) }),
threads: ({ project } = {}) => channel('thread.list', project === undefined ? {} : { project }),
threads: ({ project, shared } = {}) => channel('thread.list', { ...(project === undefined ? {} : { project }), ...(shared === true ? { shared: true } : {}) }),
shares: ({ thread }) => channel('thread.shares', { thread }),
share: ({ thread, to }) => channel('thread.share', { thread, to }),
unshare: ({ thread, to }) => channel('thread.unshare', { thread, to }),
createThread: ({ project, name, instance }) => channel('thread.create', { project, name, ...(instance === undefined ? {} : { instance }) }),
sessions: ({ thread }) => channel('session.list', { thread }),
createSession: ({ thread, name, op }) => channel('session.create', { thread, name, op }),
+1 -1
View File
@@ -1232,7 +1232,7 @@
window.MosaicChat?.leave(path);
if (path === '/chat' || path.startsWith('/chat/')) {
// Row 177 (#1675): the tester Console writes to its own threads.
await window.MosaicChat.render({ path, container: $('bus-view'), canSend: canSend || isTester, navigated });
await window.MosaicChat.render({ path, container: $('bus-view'), canSend: canSend || isTester, tester: isTester, navigated });
if (g !== gen || out()) return;
shown = route; schedule(); return;
}
+81 -23
View File
@@ -57,23 +57,35 @@
const disabledSession = pending => disabled(pending || selectedInactive());
const disabled = pending => !current.canSend || out() || pending ? ' disabled' : '';
const stamp = at => `<time>${txt(at)}</time>`;
// Per-grant UI hints; all writes still use the caller's bus principal.
const scopeWrites = (project, instance) => !current.user || current.chatGrants.some(g =>
(g.projects.includes('*') || g.projects.includes(project)) &&
(instance === undefined || g.instances.includes('*') || g.instances.includes(instance)));
const threadWrites = () => !!current.thread && !current.shared && scopeWrites(current.thread?.project, current.thread?.instance);
function sharing(t) {
if (!current.canSend || !threadWrites() || current.tester) return '';
const f = form('share:' + t.id);
return `<section aria-label="Thread sharing"><h2>Share read-only</h2><p>Share with a user (user:id), the business owner's name, or * for everyone granted on this thread.</p><ul>${(current.shares ?? []).map(r => `<li>${txt(r.to)} <button class="btn" type="button" data-unshare="${esc(r.to)}"${disabled(f.pending)}>Unshare</button></li>`).join('')}</ul><form id="chat-share-form"><label>Share with<input id="chat-share-target" value="${esc(f.to ?? '')}" maxlength="200" required></label><button class="btn" type="submit"${disabled(f.pending)}>Share read-only</button></form><p role="status">${txt(f.receipt)}</p></section>`;
}
function creation(kind, key) {
const f = form(key), project = kind === 'project';
if (current.user && (project || !scopeWrites(key))) return '';
const repo = project && current.repos.length ? `<label>Repository (optional)<select id="chat-repo" data-chat-field="repo"><option value="">No repository link</option>${current.repos.map(r => `<option value="${esc(r)}"${r === f.repo ? ' selected' : ''}>${txt(r)}</option>`).join('')}</select></label>` : '';
const choices = current.instances ?? [];
const choices = (current.instances ?? []).filter(r => scopeWrites(key, r.instance));
const picker = project ? '' : `<label>Thread with<select id="chat-instance" data-chat-field="instance" required${disabled(f.pending || !!current.instanceError || !choices.length)}><option value="">Choose who this thread is with</option>${choices.map(row => `<option value="${esc(row.instance)}"${row.instance === f.instance ? ' selected' : ''}>${txt(row.name === row.instance ? row.instance : `${row.name} (${row.instance})`)}</option>`).join('')}</select></label>`;
const unavailable = !project && (!!current.instanceError || !choices.some(row => row.instance === f.instance));
const status = !project && current.instanceError ? current.instanceError : f.receipt || (!project && !choices.length ? 'No thread instances are configured.' : '');
return `<form id="chat-${kind}-form" class="chat-create" data-key="${esc(key)}"><h2>New ${kind}</h2><label>Name<input id="chat-${kind}-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending)}></label>${repo}${picker}<button class="btn" type="submit"${disabled(f.pending || unavailable)}>${f.pending ? 'Creating…' : `Create ${kind}`}</button><p role="status">${txt(status)}</p></form>`;
}
function threads(rows) {
return rows.length ? `<ul class="chat-thread-list">${rows.map(t => `<li>${link('threads', t.id, t.name)} <span class="muted">${txt(t.instance)}</span></li>`).join('')}</ul>` : '<p>No threads yet.</p>';
return rows.length ? `<ul class="chat-thread-list">${rows.map(t => `<li>${link('threads', t.id, t.name)} <span class="muted">${txt(t.instance)}${t.shared ? ` · Shared by ${txt(t.owner)}` : ''}</span></li>`).join('')}</ul>` : '<p>No threads yet.</p>';
}
const sessionPath = (thread, session) => `#/chat/threads/${thread}/sessions/${session}`;
const countLabel = value => !value ? 'unavailable (count collector not installed)' :
value.known === true && Number.isSafeInteger(value.count) && value.count >= 0 ? String(value.count) :
`unknown (${value.reason ?? 'not-collected'})`;
function sessionControls(t) {
if (!threadWrites()) return '<p>Read-only thread.</p>';
if (current.legacyDefault) return '<p role="status">Sessions are not available for this account.</p>';
const c = current, selected = c.sessions.find(r => r.id === c.session), f = form('session:' + t.id);
const clear = form('clear:' + c.key);
@@ -84,7 +96,7 @@
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 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)}${sessionControls(t)}${sharing(t)}<p class="muted">Reading never wakes the assistant. An accepted message is not an answer.</p>${threadWrites() ? `<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>${threadWrites() ? `<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;
@@ -98,16 +110,16 @@
let html = '<div class="chat-area"><p><a href="#/chat">Chat projects</a></p>';
if (c.error) html += `<p class="chat-error banner err" role="alert">${txt(c.error)}</p>`;
if (c.kind === 'thread') {
const t = c.threads.find(t => t.id === c.id);
const t = c.thread;
if (t && (c.session || c.legacyDefault) && !c.refused) html += conversation(t);
else html += heading('Thread unavailable');
} else if (c.kind === 'project') {
const p = c.projects.find(p => p.id === c.id);
html += heading(p?.name ?? 'Project unavailable');
if (p && !c.refused) html += threads(c.threads) + creation('thread', c.id);
if (p && !c.refused) html += threads(c.threads.filter(t => !t.shared)) + '<h2>Shared with me</h2>' + threads(c.threads.filter(t => t.shared)) + creation('thread', c.id);
} else {
html += heading('Chat') + '<p>Business projects and your named assistant threads.</p><div class="chat-projects">' +
c.projects.map(p => `<section class="panel"><h2>${link('projects', p.id, p.name)}</h2>${p.repo ? `<p class="muted">Repository: ${txt(p.repo)}</p>` : ''}${threads(c.threads.filter(t => t.project === p.id))}</section>`).join('') + (c.projects.length ? '' : '<p>No projects yet.</p>') + '</div>' + creation('project', 'projects');
c.projects.map(p => `<section class="panel"><h2>${link('projects', p.id, p.name)}</h2>${p.repo ? `<p class="muted">Repository: ${txt(p.repo)}</p>` : ''}${threads(c.threads.filter(t => t.project === p.id && !t.shared))}</section>`).join('') + (c.projects.length ? '' : '<p>No projects yet.</p>') + '</div><h2>Shared with me</h2>' + threads(c.threads.filter(t => t.shared)) + creation('project', 'projects');
}
if (!c.canSend) html += '<p class="muted">Creating and sending require signed-in send mode. This Console only reads.</p>';
c.container.innerHTML = html + '</div>';
@@ -132,7 +144,7 @@
err.code === 'thread-not-found' ? 'Thread not found.' :
err.code === 'session-not-found' ? 'Session not found in this thread.' : 'Chat could not be read. Refresh to try again; your draft is kept.';
}
async function render({ path, container, canSend, navigated = false, next = false }) {
async function render({ path, container, canSend, user = false, tester = false, navigated = false, next = false }) {
if (out()) return;
const match = /^\/chat(?:\/(projects|threads)\/([0-9a-f-]+)(?:\/sessions\/([0-9a-f-]+))?)?$/.exec(path);
controller?.abort(); controller = new AbortController();
@@ -144,23 +156,46 @@
paint(); return;
}
const old = current;
current = { container, kind, id, canSend, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', refused: false, legacyDefault: false, sessions: old && old.id === id ? old.sessions ?? [] : [], session: match[3] ?? (old && old.id === id ? old.session : null), key: match[3] ? id + '/' + match[3] : old && old.id === id ? old.key : id };
current = { container, kind, id, canSend, user, tester, chatGrants: [], thread: old && old.id === id ? old.thread : null, shared: old && old.id === id ? old.shared : false, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', refused: false, legacyDefault: false, sessions: old && old.id === id ? old.sessions ?? [] : [], session: match[3] ?? (old && old.id === id ? old.session : null), key: match[3] ? id + '/' + match[3] : old && old.id === id ? old.key : id };
if (navigated || !old) container.innerHTML = '<p class="chat-loading" role="status">Reading chat…</p>';
try {
if (user) {
const config = await fetch('/api/config', { cache: 'no-store', redirect: 'manual', signal });
if (config.type === 'opaqueredirect' || config.status === 401) { signOut(); return; }
if (!config.ok) throw new ChatError('invalid-response');
const hints = await config.json();
if (g !== generation || out()) return;
current.chatGrants = Array.isArray(hints.chatGrants) ? hints.chatGrants : [];
current.canSend = hints.send === true;
}
// Resolve the explicit session before touching history/cursors. Missing selectors never
// silently fall back to a sibling. The saved default is used only for thread-only routes.
const [projects, listed, catalog] = await Promise.all([
const [projects, listed, shared] = await Promise.all([
request('projects', undefined, signal),
request('threads' + (kind === 'project' ? '?project=' + id : ''), undefined, signal),
kind === 'thread' ? request(`threads/${id}/sessions`, undefined, signal).catch(err => {
// A denied optional catalog does not revoke an existing default-thread grant.
// Explicit selectors and every other failure keep their refusal/error state.
if (err.code === 'access-refused' && !match[3]) return null;
throw err;
}) : null,
tester ? { rows: [] } : request('threads?shared=true' + (kind === 'project' ? '&project=' + id : ''), undefined, signal).catch(err => {
if (['signed-out', 'access-revoked'].includes(err.code)) throw err;
return { rows: [] };
}),
]);
if (g !== generation || out()) return;
current.projects = projects.rows; current.threads = listed.rows;
current.projects = projects.rows;
const own = new Set(listed.rows.map(t => t.id));
current.threads = [...listed.rows, ...shared.rows.filter(t => !own.has(t.id)).map(t => ({ ...t, shared: true }))];
current.thread = current.threads.find(t => t.id === id);
current.shared = current.thread?.shared === true;
const catalog = kind === 'thread' && !current.shared ? await request(`threads/${id}/sessions`, undefined, signal).catch(err => {
if (err.code === 'access-refused' && !match[3]) return null;
throw err;
}) : null;
if (g !== generation || out()) return;
if (current.shared && match[3]) throw new ChatError('session-not-found', 404);
current.shares = [];
if (kind === 'thread' && current.canSend && threadWrites() && !tester) {
const shares = await request(`threads/${id}/shares`, undefined, signal).catch(() => ({ rows: [] }));
if (g !== generation || out()) return;
current.shares = shares.rows;
}
let s = null, page = null, after = null;
if (kind === 'thread' && catalog === null) {
current.legacyDefault = true; current.sessions = []; current.session = null;
@@ -208,13 +243,13 @@
if (g !== generation || out()) return;
if (!listed.rows.every(row => typeof row?.instance === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,159}$/.test(row.instance) && typeof row.name === 'string'))
throw new ChatError('invalid-response');
current.instances = listed.rows; current.instanceError = '';
current.instances = listed.rows.filter(r => scopeWrites(id, r.instance)); current.instanceError = '';
const f = form(id);
if (f.instance && !listed.rows.some(row => row.instance === f.instance)) {
if (f.instance && !current.instances.some(row => row.instance === f.instance)) {
f.instance = ''; f.instanceChosen = true;
f.receipt = 'The selected instance is no longer available. Choose who this thread is with.';
}
if (!f.instanceChosen && !f.instance && listed.rows.length === 1) f.instance = listed.rows[0].instance;
if (!f.instanceChosen && !f.instance && current.instances.length === 1) f.instance = current.instances[0].instance;
} catch (err) {
if (err.name === 'AbortError' || out() || g !== generation) return;
current.instances = [];
@@ -244,7 +279,7 @@
for (const key of forms.keys()) if (key.includes(id)) forms.delete(key);
}
if (err.status === 403 || err.status === 404) {
current.projects = []; current.threads = []; current.refused = true;
current.projects = []; current.threads = []; current.thread = null; current.shared = false; current.refused = true;
}
paint(navigated);
}
@@ -258,6 +293,7 @@
document.addEventListener('input', event => {
if (!current || out()) return;
if (event.target.id === 'chat-operation') { const f = form('clear:' + current.key); if (f.pending) return; f.op = event.target.value; f.record = null; const button = document.getElementById('chat-cancel'); if (button) button.disabled = !current.canSend || f.pending || !f.op; }
if (event.target.id === 'chat-share-target') form('share:' + current.id).to = event.target.value;
if (event.target.id === 'chat-body') state(current.key ?? current.id).draft = event.target.value;
const f = event.target.closest('.chat-create');
if (f && event.target.dataset.chatField) {
@@ -275,6 +311,8 @@
async function write(action, key, value, path) {
if (!current || !current.canSend || out()) return;
const isThread = action === 'send' || action === 'open';
if ((isThread || action === 'createSession') && !threadWrites()) return;
if (action === 'createProject' && current.user || action === 'createThread' && !scopeWrites(value.project, value.instance)) return;
const targetThread = current.id;
const s = isThread ? state(key) : form(key);
if (s.pending || isThread && selectedInactive()) return;
@@ -314,7 +352,9 @@
document.addEventListener('submit', event => {
if (!current || !event.target.id.startsWith('chat-')) return;
event.preventDefault();
if (event.target.id === 'chat-send-form') {
if (event.target.id === 'chat-share-form') {
void changeShare('share', form('share:' + current.id).to ?? '');
} else if (event.target.id === 'chat-send-form') {
const id = current.id, session = current.session, key = current.key;
void write('send', key, { body: state(key).draft, ...(session ? { session } : {}) }, `threads/${id}/messages`);
} else if (event.target.id === 'chat-session-form') {
@@ -336,6 +376,7 @@
});
document.addEventListener('click', event => {
if (!current || out()) return;
if (event.target.dataset.unshare) void changeShare('unshare', event.target.dataset.unshare);
if (event.target.id === 'chat-resume') void write('open', current.key, current.session ? { session: current.session } : {}, `threads/${current.id}/open`);
if (event.target.id === 'chat-cancel') void cancelClear();
if (event.target.id === 'chat-clear') void clearContext();
@@ -347,6 +388,23 @@
location.hash = sessionPath(current.id, event.target.value);
}
});
async function changeShare(op, to) {
if (!current?.canSend || !threadWrites() || current.tester || out()) return;
const id = current.id, f = form('share:' + id);
if (f.pending || !to.trim()) return;
f.pending = true; f.receipt = ''; paint();
try {
const r = await request(`threads/${id}/${op}`, { to });
if (r.thread !== id || r.to !== to || r.op !== op) throw new ChatError('outcome-unknown');
f.receipt = op === 'share' ? 'Shared read-only.' : 'Share ended.';
if (op === 'share') f.to = '';
} catch (e) {
f.receipt = e.sent === false ? `Share refused (${inert(e.code)}).` : 'Outcome unknown. Read shares before trying again; nothing was retried.';
} finally {
f.pending = false;
if (!out() && current?.id === id) { await refresh(); paint(); }
}
}
const clearText = r => r.state === 'cancelled' ? `Operation ${r.op}: ${r.retired === true ? 'startup cancelled; old session remains retired' : 'replacement cancelled; old session active'}. Cancelled inputs are not replayed.` : `Operation ${r.op}: ${r.state}; successor ${r.successor}; start ${r.start ?? 'pending'}${r.refusal ? '; ' + r.refusal : ''}. No stop is forced.`;
async function checkClear(thread, key, f) {
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,159}$/.test(f.op ?? '')) { f.receipt = 'Enter a valid operation key.'; paint(); return; }
@@ -361,7 +419,7 @@
}
async function cancelClear() {
const c = current, key = c.key, f = form('clear:' + key);
if (!c.canSend || out() || f.pending || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,159}$/.test(f.op ?? '')) return;
if (!c.canSend || !threadWrites() || out() || f.pending || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,159}$/.test(f.op ?? '')) return;
if (!window.confirm(`Cancel clear operation ${f.op}? Before retirement this restores the old session to active. After retirement it only cancels successor startup. Cancelled messages are never replayed, and nothing is un-retired.`)) return;
const op = f.op;
f.pending = true; paint();
@@ -379,7 +437,7 @@
}
async function clearContext() {
const c = current, key = c.key, selected = c.sessions.find(r => r.id === c.session), f = form('clear:' + key);
if (!c.canSend || out() || f.pending || selectedInactive() || !Number.isSafeInteger(selected?.revision)) return;
if (!c.canSend || !threadWrites() || out() || f.pending || selectedInactive() || !Number.isSafeInteger(selected?.revision)) return;
if (!window.confirm(`Clear context for "${selected.name}"? This ends this session after a safe stop and starts fresh context. History and the old checkout (including unpushed work) are retained. The successor uses a fresh approved checkout; old work is not copied. This can wait for safe stop or capacity.`)) return;
// A lost answer retains this key. Only status reads are allowed afterward.
if (f.op && !['cancelled', 'failed'].includes(f.record?.state)) { await checkClear(c.id, key, f); return; }
+2 -1
View File
@@ -9,10 +9,11 @@
return path.startsWith('/chat') ? path : '/chat';
};
function show(navigated) {
if (ready && window.MosaicChat) void window.MosaicChat.render({ path: route(), container: $('chat'), canSend, navigated });
if (ready && window.MosaicChat) void window.MosaicChat.render({ path: route(), container: $('chat'), canSend, user: true, navigated });
}
document.addEventListener('mosaic-signed-out', () => { $('signed-out').hidden = false; $('sign-out').hidden = true; });
window.addEventListener('hashchange', () => show(true));
setInterval(() => { if (!document.hidden) show(false); }, 5000);
fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(async res => {
if (res.type === 'opaqueredirect' || res.status === 401) { document.dispatchEvent(new Event('mosaic-signed-out')); return; }
const config = await res.json();
+3 -2
View File
@@ -632,7 +632,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, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, upstream, files } = owner, granted = false, chatGrants = [];
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
@@ -700,6 +700,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
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;
chatGrants = sso.chatGrants?.(session) ?? [];
}
}
if (named && preview !== null) {
@@ -839,7 +840,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
// `send`: the page shows the composer only where a send can pass (the public name, --send on).
// `edit`: the editor shows Save only where a save can pass, the same test as the route's.
// `user` and `federation` (FED R6) only when there is one, so the owner's config is unchanged.
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(granted ? { user: true } : {}), ...(hub ? { federation: hub } : {}), send: !tester && Boolean(send) && named, decide: !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !tester && !granted && Boolean(send) && Boolean(projectSave) && named });
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(hub ? { federation: hub } : {}), send: !tester && Boolean(send) && named, decide: !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !tester && !granted && Boolean(send) && Boolean(projectSave) && named });
if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) });
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
const file = files.get(path);
+1
View File
@@ -352,6 +352,7 @@ export async function createSso(config, { secrets, fetch: customFetch = null, lo
return {
publicOrigin, publicHost, sessions, logins, handle, session, returnURL,
chatGrants: session => users?.chatGrants?.(session) ?? [],
health: () => Object.fromEntries([...providers].map(([id, e]) => [id, e.state])),
formTargets: () => [...new Set([...providers.values()].filter(e => e.as?.end_session_endpoint).map(e => new URL(e.as.end_session_endpoint).origin))],
summary: () => config.providers.map(p => ({ id: p.id, issuer: p.issuer, subjects: p.subjects.length })),
@@ -0,0 +1,87 @@
// Row 208: isolated real broker + chat HTTP; no config, slot, provider or live host.
import { createServer, request } from 'node:http';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { Broker } from '../../bus/src/broker.mjs';
import { Store } from '../../bus/src/store.mjs';
import { createChat } from '../src/chat.mjs';
import { chatHTTP } from '../src/chat-http.mjs';
import { consoleAccess } from '../src/access.mjs';
import { BusReadError } from '../src/bus.mjs';
export const mutants = {
composer: ["${threadWrites() ? `<form id=\"chat-send-form\"", "${true ? `<form id=\"chat-send-form\""],
open: ['${threadWrites() ? `<button class="btn" type="button" id="chat-resume"', '${true ? `<button class="btn" type="button" id="chat-resume"'],
share: ["if (!current.canSend || !threadWrites() || current.tester) return '';", "if (!current.canSend || current.tester) return '';"],
create: ["if (current.user && (project || !scopeWrites(key))) return '';", "if (current.user && project) return '';"],
};
export async function sharingFixture(t, { owner = false, mutant = process.env.POE_UI_MUTANT } = {}) {
const dir = mkdtempSync(join(tmpdir(), 'poe-sharing-'));
const store = new Store(dir), calls = [], lines = [];
const access = { accessVersion: 1, revision: 1, users: [
{ id: 'alice', home: 'local', subjects: [{ provider: 'fake', sub: 'alice-sub' }] },
{ id: 'bob', home: 'local', subjects: [{ provider: 'fake', sub: 'bob-sub' }] },
], grants: ['alice', 'bob'].map(user => ({ id: 'g-' + user, user, business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator' })) };
const broker = new Broker({ store, access: () => access, businesses: { demo: {
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } }, assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } } },
definitions: { 'chief-of-staff': {}, assistant: { messaging: JSON.parse(readFileSync(new URL('../../../roles/assistant.json', import.meta.url))).messaging } },
} } });
const human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const invoke = (cap, verb, args = {}) => broker.request(cap, { verb, args });
const alice = () => broker.bindUser({ business: 'demo', user: 'alice' });
const bob = () => broker.bindUser({ business: 'demo', user: 'bob' });
const project = invoke(human, 'project.create', { name: 'Viewer project' }).id;
const other = invoke(human, 'project.create', { name: 'Collaborator project' }).id;
const own = invoke(alice(), 'thread.create', { project, instance: 'assistant', name: 'Old own thread' }).id;
const writable = invoke(alice(), 'thread.create', { project: other, instance: 'assistant', name: 'Writable thread' }).id;
const shared = invoke(bob(), 'thread.create', { project, instance: 'assistant', name: 'Shared thread <img src=x onerror=alert(1)>' }).id;
invoke(alice(), 'thread.send', { thread: own, body: 'Own history' });
invoke(bob(), 'thread.send', { thread: shared, body: 'Shared history' });
invoke(bob(), 'thread.share', { thread: shared, to: 'user:alice' });
access.grants[0].level = 'viewer';
const hints = consoleAccess({ load: () => access, business: () => 'demo' });
const principal = () => owner ? human : alice();
const chat = createChat({ call: async (verb, args) => {
calls.push([verb, args]);
if (verb === 'plugins') return { plugins: { channel: ['webui'] } };
try { return invoke(principal(), verb, args); }
catch (e) { throw new BusReadError(e.code); }
} });
const source = readFileSync(new URL('../src/public/chat.js', import.meta.url), 'utf8');
let chatSource = source;
if (mutant) {
const [before, after] = mutants[mutant] ?? [];
if (!before || !source.includes(before)) throw Error('mutant anchor missing: ' + mutant);
chatSource = source.replace(before, after);
}
const server = createServer(async (req, res) => {
const url = new URL(req.url, 'http://fixture');
if (url.pathname === '/api/config') {
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
return res.end(JSON.stringify({ send: true, user: !owner, chatGrants: hints.chatGrants({ user: 'alice', provider: 'fake', sub: 'alice-sub' }) }));
}
if (url.pathname.startsWith('/api/chat/')) return chatHTTP(req, res, {
path: url.pathname, search: url.search, chat, named: true, send: true,
preview: { sso: true, user: owner ? 'jason' : 'alice', origin: origin() }, log: line => lines.push(line),
});
if (url.pathname === '/chat.js') { res.writeHead(200, { 'content-type': 'text/javascript' }); return res.end(chatSource); }
if (url.pathname === '/') {
res.writeHead(200, { 'content-type': 'text/html' });
return res.end('<!doctype html><title>Sharing fixture</title><div id="view"></div><script src="/chat.js"></script>');
}
res.writeHead(404); res.end();
});
await new Promise(r => server.listen(0, '127.0.0.1', r));
const origin = () => `http://127.0.0.1:${server.address().port}`;
t.after(async () => { await new Promise(r => server.close(r)); store.close(); rmSync(dir, { recursive: true, force: true }); });
const http = (path, value, extra = {}) => new Promise((resolve, reject) => {
const body = value === undefined ? undefined : JSON.stringify(value);
const req = request(origin() + path, { method: body ? 'POST' : 'GET', headers: body ? { origin: origin(), 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', ...extra } : extra }, res => {
let data = ''; res.on('data', chunk => { data += chunk; });
res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(data) }));
}); req.on('error', reject); req.end(body);
});
return { store, access, broker, human, alice, bob, invoke, project, other, own, writable, shared, calls, lines, origin: origin(), http, hints, source, chatSource };
}
@@ -0,0 +1,164 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { sharingFixture } from './thread-sharing-fixture.mjs';
import { browser } from './browser.mjs';
import { startServer } from '../src/serve.mjs';
import { consoleBus } from '../src/console-bus.mjs';
async function page(t, f) {
const b = await browser(); t.after(() => b.close());
await b.navigate(f.origin);
const render = async path => {
await b.evaluate(`(async()=>{location.hash=${JSON.stringify('#' + path)};await MosaicChat.render({path:${JSON.stringify(path)},container:document.getElementById('view'),canSend:true,user:${!f.owner},navigated:true});return true;})()`);
};
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}return false;})()`);
return { b, render, wait };
}
const counts = f => Object.fromEntries(['threads', 'messages', 'thread_opens', 'thread_shares'].map(table => [table, f.store.get(`SELECT count(*) n FROM ${table}`).n]));
test('193d HTTP shares use the caller, plain/shared lists, exact args and write fences', async t => {
const f = await sharingFixture(t);
let r = await f.http('/api/chat/threads?shared=true');
assert.equal(r.status, 200); assert.deepEqual(r.body.rows.map(x => x.id), [f.shared]);
assert.equal(r.body.rows[0].owner, 'user:bob');
r = await f.http('/api/chat/threads');
assert.deepEqual(r.body.rows.map(x => x.id), [f.own, f.writable]);
assert.ok(r.body.rows.every(x => !Object.hasOwn(x, 'owner')), 'plain list shape unchanged');
assert.equal((await f.http(`/api/chat/threads/${f.shared}/messages`)).status, 200);
const before = counts(f);
for (const [path, body] of [
['/api/chat/threads', { project: f.project, instance: 'assistant', name: 'forbidden' }],
[`/api/chat/threads/${f.own}/messages`, { body: 'forbidden' }],
[`/api/chat/threads/${f.own}/open`, {}],
[`/api/chat/threads/${f.own}/share`, { to: 'user:bob' }],
[`/api/chat/threads/${f.own}/unshare`, { to: 'user:bob' }],
]) {
r = await f.http(path, body);
assert.equal(r.status, 403, path); assert.equal(r.body.error, 'access-refused'); assert.equal(r.body.sent, false);
}
assert.equal((await f.http(`/api/chat/threads/${f.own}/shares`)).body.error, 'access-refused');
assert.deepEqual(counts(f), before);
f.access.grants[0].level = 'collaborator';
r = await f.http(`/api/chat/threads/${f.own}/share`, { to: 'user:bob' });
assert.equal(r.status, 200); assert.deepEqual(r.body, { thread: f.own, to: 'user:bob', op: 'share' });
assert.deepEqual((await f.http(`/api/chat/threads/${f.own}/shares`)).body.rows.map(x => x.to), ['user:bob']);
assert.equal((await f.http(`/api/chat/threads/${f.own}/unshare`, { to: 'user:bob' })).status, 200);
for (const [path, body] of [
['/api/chat/threads?shared=false', undefined], ['/api/chat/threads?shared=true&shared=true', undefined],
[`/api/chat/threads/${f.own}/share`, { to: 'user:bob', owner: 'jason' }],
[`/api/chat/threads/${f.own}/share`, { to: 'user:bob', verb: 'thread.send' }],
[`/api/chat/threads/${f.own}/shares?to=user:bob`, undefined],
]) assert.equal((await f.http(path, body)).status, 400, path);
assert.equal((await f.http(`/api/chat/threads/${f.own}/share`, { to: '*' }, { origin: 'http://other.test' })).status, 403);
assert.equal((await f.http(`/api/chat/threads/${f.own}/share`, { to: '*' }, { 'sec-fetch-site': 'cross-site' })).status, 403);
f.invoke(f.bob(), 'thread.unshare', { thread: f.shared, to: 'user:alice' });
assert.equal((await f.http(`/api/chat/threads/${f.shared}/messages`)).body.error, 'thread-not-owned');
});
test('193d config supplies only the signed-in user live collaborator scopes and updates on downgrade', async t => {
const f = await sharingFixture(t);
f.access.grants.push({ ...f.access.grants[0], id: 'mixed', level: 'collaborator', projects: [f.other] });
const session = { user: 'alice', name: 'alice', provider: 'fake', sub: 'alice-sub' };
let web;
const sso = { get publicHost() { return `127.0.0.1:${web.address().port}`; }, get publicOrigin() { return `http://${this.publicHost}`; },
formTargets: () => [], session: () => session, chatGrants: s => f.hints.chatGrants(s) };
web = await startServer({ port: 0, sso, log: () => {}, userOptions: () => consoleBus(async () => [], { send: true }) });
t.after(() => new Promise(r => web.close(r)));
const read = async () => (await fetch(`http://127.0.0.1:${web.address().port}/api/config`)).json();
let config = await read();
assert.equal(config.user, true); assert.deepEqual(config.chatGrants, [{ projects: [f.other], instances: ['assistant'] }]);
assert.equal(config.send, true, 'send mode is separate from individual grant levels');
f.access.grants[2].level = 'viewer';
assert.deepEqual((await read()).chatGrants, []);
f.access.grants[2].level = 'collaborator'; f.access.grants[2].expires = new Date(Date.now() - 1000).toISOString();
assert.deepEqual((await read()).chatGrants, []);
assert.deepEqual(f.hints.chatGrants({ ...session, sub: 'bob-sub' }), []);
});
test('193d viewer UI has no composer, resume, share, session mutations or create controls', async t => {
const f = await sharingFixture(t); f.owner = false;
const { b, render } = await page(t, f);
await render('/chat');
assert.match(await b.evaluate('document.getElementById("view").textContent'), /Shared with me/);
assert.equal(await b.evaluate('!!document.querySelector("#chat-project-form")'), false);
await render('/chat/projects/' + f.project);
assert.equal(await b.evaluate('!!document.querySelector("#chat-thread-form")'), false);
for (const thread of [f.own, f.shared]) {
await render('/chat/threads/' + thread);
assert.match(await b.evaluate('document.querySelector(".chat-log").textContent'), /history/);
for (const selector of ['#chat-send-form', '#chat-resume', '#chat-share-form', '#chat-session-form', '#chat-clear', '#chat-cancel']) {
assert.equal(await b.evaluate(`!!document.querySelector(${JSON.stringify(selector)})`), false, `${thread}: ${selector}`);
}
}
assert.equal(await b.evaluate('!!document.querySelector("img")'), false, 'shared name remains text');
const sharedSessions = f.calls.filter(([verb, args]) => verb === 'session.list' && args.thread === f.shared);
assert.equal(sharedSessions.length, 0, 'shared read never requests an owner-only catalog');
});
test('193d mixed grants keep viewer project read-only and collaborator own thread writable; downgrade hides controls', async t => {
const f = await sharingFixture(t); f.owner = false;
f.access.grants.push({ ...f.access.grants[0], id: 'mixed', level: 'collaborator', projects: [f.other] });
const { b, render, wait } = await page(t, f);
await render('/chat/threads/' + f.own);
assert.equal(await b.evaluate('!!document.querySelector("#chat-send-form")'), false);
await render('/chat/threads/' + f.writable);
assert.equal(await b.evaluate('!!document.querySelector("#chat-send-form")'), true);
assert.equal(await b.evaluate('!!document.querySelector("#chat-share-form")'), true);
await b.evaluate('(()=>{const x=document.getElementById("chat-share-target");x.value="user:bob";x.dispatchEvent(new Event("input",{bubbles:true}));document.querySelector("#chat-share-form button").click();return true;})()');
assert.equal(await wait('!!document.querySelector("[data-unshare]")'), true);
assert.equal(f.store.get("SELECT count(*) n FROM thread_shares WHERE thread=? AND op='share'", f.writable).n, 1);
await b.evaluate('document.querySelector("[data-unshare]").click(),true');
assert.equal(await wait('!document.querySelector("[data-unshare]")'), true);
f.access.grants[2].level = 'viewer';
await render('/chat/threads/' + f.writable);
assert.equal(await b.evaluate('!!document.querySelector("#chat-send-form,#chat-share-form,#chat-resume")'), false);
// Loss of a share clears visible history on the next render.
await render('/chat/threads/' + f.shared);
f.invoke(f.bob(), 'thread.unshare', { thread: f.shared, to: 'user:alice' });
await render('/chat/threads/' + f.shared);
assert.equal(await b.evaluate('!!document.querySelector(".chat-log")'), false);
});
test('193d owner has share controls on own threads and shared threads remain read-only', async t => {
const f = await sharingFixture(t, { owner: true }); f.owner = true;
const own = f.invoke(f.human, 'thread.create', { project: f.project, instance: 'assistant', name: 'Owner thread' }).id;
f.invoke(f.bob(), 'thread.share', { thread: f.shared, to: 'jason' });
const { b, render } = await page(t, f);
await render('/chat/threads/' + own);
assert.equal(await b.evaluate('!!document.querySelector("#chat-share-form")'), true);
await render('/chat/threads/' + f.shared);
assert.match(await b.evaluate('document.querySelector(".chat-log").textContent'), /Shared history/);
assert.equal(await b.evaluate('!!document.querySelector("#chat-share-form,#chat-send-form,#chat-resume")'), false);
});
// Run under each served-source mutant after the normal absence test kills it.
// Every browser POST bypasses all local UI checks and still uses the viewer transport.
test('193d bypassed UI checks still reach a refusing bus for every viewer write', async t => {
const f = await sharingFixture(t); f.owner = false;
const { b, render } = await page(t, f);
await render('/chat/threads/' + f.own);
const before = counts(f);
for (const [path, value] of [
['threads', { project: f.project, instance: 'assistant', name: 'mutant create' }],
[`threads/${f.own}/messages`, { body: 'mutant send' }],
[`threads/${f.own}/open`, {}], [`threads/${f.own}/share`, { to: 'user:bob' }],
]) {
const r = JSON.parse(await b.evaluate(`(async()=>{const r=await fetch('/api/chat/'+${JSON.stringify(path)},{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(${JSON.stringify(value)})});return JSON.stringify({status:r.status,body:await r.json()});})()`));
assert.equal(r.status, 403, path); assert.equal(r.body.error, 'access-refused'); assert.equal(r.body.sent, false);
}
assert.deepEqual(counts(f), before);
for (const verb of ['thread.create', 'thread.send', 'thread.open', 'thread.share']) assert.ok(f.calls.some(([v]) => v === verb), `${verb} reached the bus`);
});
test('193d collaborator shares stay read-only and never expose reshare or wake controls', async t => {
const f = await sharingFixture(t); f.owner = false;
f.access.grants[0].level = 'collaborator';
const { b, render } = await page(t, f);
await render('/chat/threads/' + f.shared);
assert.equal(await b.evaluate('!!document.querySelector("#chat-share-form,#chat-send-form,#chat-resume,#chat-session-form")'), false);
for (const [action, body] of [['messages', { body: 'shared write' }], ['open', {}], ['share', { to: '*' }]]) {
const r = await f.http(`/api/chat/threads/${f.shared}/${action}`, body);
assert.equal(r.status, 403); assert.equal(r.body.error, 'thread-not-owned');
}
});