Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a4bef6c13 | ||
|
|
ec86f2c847 | ||
|
|
4ec279dc43 | ||
|
|
3d2569ce25 | ||
|
|
3a20f29d66 |
No files matched your search
@@ -5457,3 +5457,15 @@ Row 299 (573, #1806, Gander, Filbert): before, the P10 band test timed a fault r
|
||||
2026-10-24 | Goldie | Row325 / #1821 before (mos-1563/1564) | Real .mosaic/project.json idstack disagrees with Case1 project's mosaic-stack key; row323 scratch fixture omitted project file and only warned. New goldie/row325 at99930362e changes only that JSON key and expressly authorized old assertion key; new real-byte project-copy validation tests require valid and reject old key. Mutant prepared; no tests before Sage CLEAR, no queue writes/host actions.
|
||||
|
||||
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 — 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 — 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.
|
||||
@@ -0,0 +1,10 @@
|
||||
# Bentina
|
||||
|
||||
Builder seat, Codex gpt-6.1-sol, started by Sage under Q246 and mos-1566. Owns row 157, I2: the install images, the
|
||||
install config key, the endpoint helper and the egress proxy (docs/plans/2026-10-18_containerized-install-rows.md),
|
||||
reassigned from Researcher so the coding runs on Codex.
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no credentials of its
|
||||
own; it pushes only its own `bentina/*` branches through the repository credential helper (mos-0981), never `refactor`,
|
||||
`main` or another seat's branch. Claude seats orchestrate and review the install path (mos-1542 rule 4). Registered by
|
||||
Sage on 2026-10-24 so the queue can name the seat as row owner (store.mjs isSeatDir).
|
||||
@@ -0,0 +1,10 @@
|
||||
# Poe
|
||||
|
||||
Builder seat, Codex gpt-6.1-sol, started by Sage under Q246 and mos-1566. Owns row 208, 193d: the Console's shared
|
||||
list, share controls and viewer read-only (docs/plans/2026-10-20_human-users-build.md), reassigned from Morgana so the
|
||||
coding runs on Codex.
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no credentials of its
|
||||
own; it pushes only its own `poe/*` branches through the repository credential helper (mos-0981), never `refactor`,
|
||||
`main` or another seat's branch. Claude seats orchestrate and review guards (mos-1542 rule 4). Registered by Sage on
|
||||
2026-10-24 so the queue can name the seat as row owner (store.mjs isSeatDir).
|
||||
@@ -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.
|
||||
@@ -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));
|
||||
@@ -1620,3 +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-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.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Case 1 business file: project key must match the project file (#414 stage 2)
|
||||
|
||||
Written 2026-10-24 by Sage for ops-0288 and Mos's ruling mos-1563.
|
||||
|
||||
## Brief: re-key the Case 1 project to stack
|
||||
|
||||
**Why.** The .53 stage 2 run on 99930362e stopped at step 8 (ops-0288). `case2-install.sh --profile factory`
|
||||
refused with:
|
||||
`validate: mosaic business: project file /srv/mosaic/src/mosaic-stack/.mosaic/project.json has id stack, but
|
||||
business mosaic-stack declares it as mosaic-stack`. Row 323's `docs/guides/case1-business.mosaic-stack.json`
|
||||
keys its one project `mosaic-stack`. The repo's `.mosaic/project.json` has id `stack`, and the loader requires
|
||||
the key to equal that id (packages/business/src/cli.mjs:80, packages/cli/src/config.mjs:67). Row 323's test
|
||||
missed it because it validates against a scratch root with no project file, which only warns (cli.mjs:74-78).
|
||||
|
||||
**Ruling (mos-1563).** Fix the business file. Don't touch the project id: `stack` is read elsewhere.
|
||||
|
||||
**What to change.**
|
||||
1. In `docs/guides/case1-business.mosaic-stack.json`, rename the `projects` key from `mosaic-stack` to
|
||||
`stack`. Keep its `root` (`/srv/mosaic/src/mosaic-stack`). Business id stays `mosaic-stack`. Change nothing
|
||||
else in the file.
|
||||
2. A new test file, `packages/business/tests/case1-project-key.test.mjs`, that validates the committed case1
|
||||
file against the repo's own `.mosaic/project.json`, so a mismatch fails in CI instead of on a host:
|
||||
- read the repo's `.mosaic/project.json` and assert the case1 file has exactly one project whose key equals
|
||||
its `id`;
|
||||
- run the business `validate` path (the same one cli.mjs:80 runs) with that project's root pointed at a
|
||||
scratch copy holding the repo's real `.mosaic/project.json` bytes, and assert it passes with the project
|
||||
reported `valid`, not `absent`;
|
||||
- a negative case: the same run with the key set back to `mosaic-stack` refuses with the cli.mjs:80
|
||||
message.
|
||||
3. The one old-test line this change breaks, `packages/business/tests/case1-business-file.test.mjs:70`
|
||||
(`assert.deepEqual(b.projects, { "mosaic-stack": ... })`), changes its key to `stack` and nothing else. This
|
||||
edit has Mos's go (mos-1564, under mos-1272). Don't change any other line of any old test. If another old
|
||||
test reds, stop and report it.
|
||||
|
||||
**Gate.** Use `export TMPDIR=$(mktemp -d /mnt/storage/scratch/tmp/XXXXXX)` (BRIEF-TEMPLATE Gate). On my CLEAR
|
||||
only: the new test file whole, then the packages/business suite whole. One mutant: set the case1 key back to
|
||||
`mosaic-stack`, show the new test red, restore and check the bytes. The cli suite counts from my landing gate.
|
||||
|
||||
**Boundaries.** No host action, no .53 contact, no credential reads, no queue writes, no push to refactor.
|
||||
Work in a new worktree off refactor at 99930362e or later, on branch `goldie/row325`. Push the branch and
|
||||
report the tip, base and manifest to Sage and to the reviewer. Record design choices in
|
||||
agents/goldie/work/queue-325/BUILD.md.
|
||||
|
||||
**Acceptance.** At a landed commit, `case2-install.sh --business mosaic-stack --business-file
|
||||
docs/guides/case1-business.mosaic-stack.json --profile factory` passes step 8's validate against
|
||||
`/srv/mosaic/src/mosaic-stack/.mosaic/project.json`, and the new test fails if the key and the project id
|
||||
drift apart again.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Host run sheet: five operator gaps from the .53 and Run B installs (#414 stage 2)
|
||||
|
||||
Written 2026-10-24 by Sage for Mos's mos-1566 item 1, from ops-0282, ops-0288, ops-0291, ops-0292,
|
||||
Duckworth's 323 N2 and Mos's rulings mos-1563 and mos-1565.
|
||||
|
||||
## Brief: five run-sheet gaps
|
||||
|
||||
**Why.** Five things stopped or slowed operators on the .53 stage 2 install and 433 Run B. Each is a gap
|
||||
in `docs/guides/host-run-sheet.md` (checked at 3d2569ce2), not a product bug. The code they describe has
|
||||
landed. This row writes them down.
|
||||
|
||||
**What to write.** Edit only `docs/guides/host-run-sheet.md`. Source every command, path and message from
|
||||
the code or the receipts named here, and cite the line you read, the way the sheet already does
|
||||
(`scripts/stack-release.sh:14`). Don't invent a flag, path or exit code.
|
||||
|
||||
1. **B2-2, the Console proof file format** (DEFERRED.md "ops-0282 B2-2", ops-0282). In section 9's .54
|
||||
library table (:321), the proof file row's File cell gives mode and owner but no format. Add it: one
|
||||
base64url line of at least 32 characters, `[A-Za-z0-9_-]` only (packages/webui/src/serve.mjs:264). The
|
||||
Console exits 2 at start otherwise (:267). `case2-unit.sh` doesn't open the file, so a bad proof fails
|
||||
only at Console start. Read serve.mjs:255-270 yourself and match what it checks, including whether a
|
||||
trailing newline is accepted.
|
||||
2. **N2, a missing project file only warns** (Duckworth 323 round 1 N2, ROUND1-6a0f0f4f8.md). When the
|
||||
project root a business file names has no `.mosaic/project.json`, `mosaic business validate` passes
|
||||
with the warning "no project file at ... it has no project variables" (packages/business/src/cli.mjs:74-78).
|
||||
An install on such a host passes step 7 with the project's variables missing. Say so where the sheet
|
||||
describes `case2-install.sh` (near :278), and tell the operator to treat that warning as a stop.
|
||||
3. **The pre-install validate step** (mos-1563, ops-0288). Before `case2-install.sh`, check by hand that
|
||||
each `projects` key in the business file equals the `id` in `<that project's root>/.mosaic/project.json`
|
||||
in the stack's user's clone, and that the file exists. The loader refuses a mismatch
|
||||
(packages/business/src/cli.mjs:80) at step 7, after the copy is made. The check must use only what the
|
||||
host has at that point: coreutils, grep, or the pinned node under `/opt/mosaic-stack/node-<v>/bin`
|
||||
(section 2), run as the stack's user. Give the expected output for a match and say a mismatch is a
|
||||
stop: fix the business file, not the project id (mos-1563).
|
||||
4. **The retry path after a failed first install** (mos-1565, ops-0291). Section 6 has no retry form.
|
||||
What happened on .53: a refused `case2-install.sh` left `<home>/.config/mosaic-dev/config.json` behind
|
||||
(bootstrap ran at step 1), and the first refusal also left an empty `businesses/` dir in that config
|
||||
dir. The operator then used the upgrade form's fetch, which writes the bundle to
|
||||
`refs/remotes/origin/refactor` and doesn't move `refs/heads/refactor`, so `stack-copy.sh copy` refused
|
||||
with "not reachable from refs/heads/refactor". Mos's ruling: move the clones aside and rerun section
|
||||
6's first-install form. Write that as a short "If the first install refuses" block in section 6:
|
||||
- which paths move aside: root's checkout `/root/mosaic-stack`, the stack's user's clone
|
||||
`/srv/mosaic/src/mosaic-stack`, and the stack's user's config dir. Read `scripts/case2-install.sh`
|
||||
(bootstrap at step 1, :172, :200, :210-211) and `scripts/bootstrap.sh` to list what an install
|
||||
writes before it can refuse, and name only those paths;
|
||||
- move, never delete: `mv` to a name with a UTC timestamp suffix, as root, keeping owner and mode;
|
||||
- what the operator must re-place afterwards if a credential slot under that config dir was already
|
||||
filled (section 9). If nothing in section 9 can be filled before the first install, say that;
|
||||
- then rerun the first-install block from its first line. Never use the upgrade form's fetch on a
|
||||
clone that a first install hasn't finished with.
|
||||
5. **Checking a backup's SHA256SUMS by hand** (ops-0292). The sheet has no backup check today. A backup set
|
||||
under `<dataRoot>/backups/stack/<activation>/` holds `SHA256SUMS` with paths relative to `bus/`
|
||||
(scripts/stack-release.sh:482 writes `(cd "$o/bus" && sha256sum bus.sqlite) > "$o/SHA256SUMS"`), and
|
||||
the tool checks it with `(cd "$o/bus" && sha256sum -c --quiet ../SHA256SUMS)` (:502, :2244). Running
|
||||
`sha256sum -c SHA256SUMS` from the backup dir itself fails (rc 1, "FAILED"), because `bus.sqlite` is
|
||||
in `bus/`. Add a short paragraph after the upgrade exits in section 6 that gives the `bus/` form for an
|
||||
operator who wants to check a backup before a rollback or restore. Read :475-510 to confirm the
|
||||
layout first.
|
||||
|
||||
Keep the sheet's voice: short sentences, the path and line for each claim, no em dashes. Don't renumber
|
||||
sections. Don't touch any other file except your BUILD.md.
|
||||
|
||||
**Test.** None ships. Before the review request, show that no test or script reads the sheet:
|
||||
`git grep -n host-run-sheet -- 'packages/*/tests/*' 'scripts/*'` prints nothing, with a positive control
|
||||
that the same command form finds a path you know a test reads. Also grep the tests for a 64-hex pin next
|
||||
to the sheet's name. If any test reads the sheet, stop and tell Sage.
|
||||
|
||||
**Gate.** Doc only (decision 137). No suite run is needed from you, and no CLEAR. Sage's landing gate
|
||||
runs every suite (mos-1455). The reviewer checks every new claim against the cited line.
|
||||
|
||||
**Boundaries.** No host action, no .53, .54 or 1013 contact, no credential reads, no push to refactor.
|
||||
Claimant queue moves are yours (start, in-review). Work in a new worktree off refactor at 3d2569ce2 or
|
||||
later, on branch `goldie/row-run-sheet`. Push the branch and report the tip, base and manifest to Sage
|
||||
and to the reviewer. Record choices in `agents/goldie/work/queue-<row>/BUILD.md`.
|
||||
|
||||
**Acceptance.** An operator following the sheet can see the proof file format, treats the missing
|
||||
project file warning as a stop, checks the project key before install, has a written retry path after a
|
||||
refused first install, and can check a backup by hand with the form that works. Every new claim cites a
|
||||
line that says it.
|
||||
+3
-2
@@ -33,7 +33,7 @@ Gaps found while working go to `docs/plans/DEFERRED.md`, not here.
|
||||
|
||||
<!-- mosaic-queue:begin -->
|
||||
|
||||
Generated from `docs/plans/queue.json` revision 3778 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
Generated from `docs/plans/queue.json` revision 3788 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
|
||||
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
@@ -359,8 +359,9 @@ Generated from `docs/plans/queue.json` revision 3778 by `queue render`. Do not e
|
||||
| 320 | Case 1/2 host fixes for the 433 P1 rerun: hostcheck item 5 mode 750, run sheet bundle verify, case2-unit --claude @, stack-copy unreadable cwd, notify.json step and precheck, mosaic wrapper step (10-27 path) | gladstone; reviewers morgana | #1816 | required; done | — | Morgana approves. Touched test files run whole (case1-hostcheck, case2-unit, case2-install, stack-copy) and the 8 named mutants are killed; Sage's full landing gate runs every package suite and every scripts/test-*.sh. After landing Mos sends ops-01 the new N. (sage) | `docs/plans/2026-10-23_host-fixes-1816-brief.md` § Brief: hostcheck item 5, bundle verify, case2-unit @, stack-copy cwd | LANDED 094124b4e 21:53Z (replay of faf55618e onto 45e3ca7bd, code paths patch-id equal, log appends in one final commit). F1-F6 hostcheck item 5, bundle verify, case2-unit scoped paths and cwd, precheck notify decision, run sheet 9a notify dirs 0700, wrapper step. Morgana APPROVE r2 c31403 rev 3715. Full gate GREEN 21:53:00Z, 0 fail (mos-1453/1455/1456). Vikunja 614 done. 433 P1 reruns on this N (mos-1532). |
|
||||
| 321 | F7, F10, F11: run sheet wrapper step umask and node PATH under sudo; adopt step after a first install; --no-rehearsal on the first upgrade; wait for healthz 200 before a precheck (433, ops-0271/0274/0275); docs only | gladstone; reviewers boyd | #1817 | required; done | — | Boyd (Codex) approves. A git grep with a positive control shows no test reads the changed run sheet lines; BUILD.md records the diff and the grep. Doc only, lands with any row (mos-1544). (sage) | `docs/plans/2026-10-23_host-fixes-1816-brief.md` § Brief: F7, the wrapper step's PATH and umask under sudo | LANDED 5283d3745, 3fd5307e1, 3569bf54f: cherry-picks of b5d4d7903, d9400f0d3, 9aae73ade with equal patch-ids. Doc only. Pushed tip 3569bf54f 23:32:54Z 10-23. Boyd APPROVE c31450 rev 3757, grep with positive control in BUILD.md. Rode the full gate GREEN 23:32:28Z on 3569bf54f. Live unchanged 45dca69f6. |
|
||||
| 322 | F8/F9: case2-install.sh runs the library persona import from the stack copy, and its refusal carries the error line (433 Run B, ops-0273) | goldie; reviewers morgana | #1818 | required; done | — | Morgana approves (install path). The new case2-install test fails on 094124b4e and passes on the candidate; a mutant restoring REPO at :185 is killed; Sage's full landing gate runs every package suite and scripts/test-*.sh. Then ops-01 reruns Run B P1. (sage) | `docs/plans/2026-10-23_host-fixes-1816-brief.md` § Brief: F8 and F9, the library persona import runs without the clone's node_modules | LANDED 467b05fe2, cherry-pick of 7b4ea4217 on 860afc2b6; added and removed lines identical, patch-id differs only by BUILD-LOG/SESSIONS append context (both sides kept). Pushed with tip 3569bf54f 23:32:54Z 10-23. Morgana APPROVE c31451 rev 3758, N1-N3 non-blocking. Sage full gate GREEN 23:32:28Z on 3569bf54f, all suites 0 fail, every scripts/test-*.sh rc 0. Next: ops-01 Run B P1 rerun on 467b05fe2 (mos-1553). Live unchanged 45dca69f6. |
|
||||
| 323 | Case 1 factory business file for .53: docs/guides/case1-business.mosaic-stack.json plus a loader and factory-check test (#414 stage 2, ops-0277) | goldie; reviewers duckworth | #1819 | in-review, round 1, request posted | — | Sage full gate: all package suites except queue plus every scripts/test-*.sh (mos-1455); Duckworth review; new test file and one bus.tester mutant on CLEAR (sage) | `docs/plans/2026-10-23_case1-business-file-brief.md` § Brief: the Case 1 factory business file | — |
|
||||
| 323 | Case 1 factory business file for .53: docs/guides/case1-business.mosaic-stack.json plus a loader and factory-check test (#414 stage 2, ops-0277) | goldie; reviewers duckworth | #1819 | done | — | Sage full gate: all package suites except queue plus every scripts/test-*.sh (mos-1455); Duckworth review; new test file and one bus.tester mutant on CLEAR (sage) | `docs/plans/2026-10-23_case1-business-file-brief.md` § Brief: the Case 1 factory business file | LANDED 571376c30 + evidence 6a0f0f4f8 (ff from dd6cb5872), tip 99930362e. Duckworth approve c31477 rev 3778 on 6a0f0f4f8. Sage gate green 00:54Z on 6a0f0f4f8: all package suites 0 fail, every scripts/test-*.sh rc 0. Root cause of the revoke-window red: TMPDIR length, tools.sock over 107 bytes (session.mjs:278). Brief template rule and DEFERRED entry in c25799928 (mos-1561). N1/N2 doc only. |
|
||||
| 324 | Tracker optional in the business file (Q247) | goldie; reviewers duckworth | #1820 | done | — | New tests only. Absent tracker: loader and validate pass, the bus boots with no credential-file error and no @sync. Present tracker unchanged. A mutant that skips the presence check is killed. Then whole touched test files, then the full landing gate. (sage) | `docs/plans/2026-10-23_optional-tracker-brief.md` § Brief: tracker becomes optional in the business file | LANDED 7e59ba8b3 + evidence dbdfcfd52 (ff from 3569bf54f), pushed with tip dd6cb5872 00:06:58Z 10-24. Duckworth APPROVE c31470 rev 3770 (static, N1 doc only). Goldie: new 5/5 and 4/4, business.test 21/21, presence mutant killed. Sage full gate GREEN 00:05:49Z on 64ea54083: every package suite 0 fail 0 skip (business 254, cli 986, bus 924, plugins 119, webui 411), every scripts/test-*.sh rc 0. Live unchanged 45dca69f6. Run B N1 for ops-01 P2/P3 (mos-1558). |
|
||||
| 325 | Case 1 business file: re-key project to stack to match .mosaic/project.json, plus a project-file test (#414 stage 2, ops-0288, mos-1563) | goldie; reviewers gladstone | #1821 | in-review, round 1, request posted | — | New test file and packages/business whole on CLEAR; one key mutant killed; Gladstone review; Sage full landing gate (mos-1455) (sage) | `docs/plans/2026-10-24_case1-project-key-brief.md` § Brief: re-key the Case 1 project to stack | — |
|
||||
|
||||
<!-- mosaic-queue:end -->
|
||||
|
||||
|
||||
+349
-9
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"canonicalRoot": "/mnt/storage/src/mosaic-stack",
|
||||
"revision": 3778,
|
||||
"revision": 3788,
|
||||
"rows": [
|
||||
{
|
||||
"id": 1,
|
||||
@@ -32646,7 +32646,7 @@
|
||||
"closes": [
|
||||
1819
|
||||
],
|
||||
"state": "in-review",
|
||||
"state": "done",
|
||||
"previousState": null,
|
||||
"required": false,
|
||||
"requiredSince": null,
|
||||
@@ -32719,15 +32719,12 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"claim": {
|
||||
"seat": "goldie",
|
||||
"op": "goldie-0323-start-1"
|
||||
},
|
||||
"note": null,
|
||||
"claim": null,
|
||||
"note": "LANDED 571376c30 + evidence 6a0f0f4f8 (ff from dd6cb5872), tip 99930362e. Duckworth approve c31477 rev 3778 on 6a0f0f4f8. Sage gate green 00:54Z on 6a0f0f4f8: all package suites 0 fail, every scripts/test-*.sh rc 0. Root cause of the revoke-window red: TMPDIR length, tools.sock over 107 bytes (session.mjs:278). Brief template rule and DEFERRED entry in c25799928 (mos-1561). N1/N2 doc only.",
|
||||
"blockedReason": null,
|
||||
"createdAt": "2026-10-23T22:50:41.494Z",
|
||||
"updatedAt": "2026-10-24T00:44:15.270Z",
|
||||
"updatedBy": "duckworth"
|
||||
"updatedAt": "2026-10-24T01:00:54.851Z",
|
||||
"updatedBy": "sage"
|
||||
},
|
||||
{
|
||||
"id": 324,
|
||||
@@ -32818,6 +32815,99 @@
|
||||
"createdAt": "2026-10-23T23:12:07.446Z",
|
||||
"updatedAt": "2026-10-24T00:08:57.664Z",
|
||||
"updatedBy": "sage"
|
||||
},
|
||||
{
|
||||
"id": 325,
|
||||
"piece": "Case 1 business file: re-key project to stack to match .mosaic/project.json, plus a project-file test (#414 stage 2, ops-0288, mos-1563)",
|
||||
"owner": "goldie",
|
||||
"issues": [
|
||||
1821
|
||||
],
|
||||
"closes": [
|
||||
1821
|
||||
],
|
||||
"state": "in-review",
|
||||
"previousState": null,
|
||||
"required": false,
|
||||
"requiredSince": null,
|
||||
"gate": "New test file and packages/business whole on CLEAR; one key mutant killed; Gladstone review; Sage full landing gate (mos-1455)",
|
||||
"gateOwner": "sage",
|
||||
"brief": {
|
||||
"path": "docs/plans/2026-10-24_case1-project-key-brief.md",
|
||||
"anchor": "Brief: re-key the Case 1 project to stack",
|
||||
"blob": "8d91484c6731c7f7ceaf197a483c95024dee7448"
|
||||
},
|
||||
"after": [],
|
||||
"reviewers": [
|
||||
"gladstone"
|
||||
],
|
||||
"review": {
|
||||
"rounds": [
|
||||
{
|
||||
"n": 1,
|
||||
"op": "goldie-0325-review-1",
|
||||
"by": "goldie",
|
||||
"at": "2026-10-24T01:18:04.364Z",
|
||||
"issue": 1821,
|
||||
"candidate": {
|
||||
"kind": "commit",
|
||||
"digest": "c5f96836d5ffa6cc0160da7360321706bef86a17",
|
||||
"text": null
|
||||
},
|
||||
"request": "comment",
|
||||
"attempts": [
|
||||
{
|
||||
"op": "goldie-0325-review-1",
|
||||
"by": "goldie",
|
||||
"at": "2026-10-24T01:18:04.364Z",
|
||||
"state": "failed",
|
||||
"comment": null,
|
||||
"transport": {
|
||||
"outcome": "failed",
|
||||
"status": null,
|
||||
"comment": null,
|
||||
"detail": "pre-send: the credential or account check failed"
|
||||
},
|
||||
"resolutions": []
|
||||
},
|
||||
{
|
||||
"op": "sage-3884",
|
||||
"by": "sage",
|
||||
"at": "2026-10-24T01:20:34.463Z",
|
||||
"state": "posted",
|
||||
"comment": 31482,
|
||||
"transport": {
|
||||
"outcome": "posted",
|
||||
"status": 201,
|
||||
"comment": 31482,
|
||||
"detail": "created"
|
||||
},
|
||||
"resolutions": []
|
||||
}
|
||||
],
|
||||
"duplicateRisk": false,
|
||||
"receipts": [
|
||||
{
|
||||
"reviewer": "gladstone",
|
||||
"op": "gladstone-325-verdict-r1",
|
||||
"at": "2026-10-24T01:22:42.822Z",
|
||||
"verdict": "approve",
|
||||
"comment": 31484,
|
||||
"candidate": "c5f96836d5ffa6cc0160da7360321706bef86a17"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"claim": {
|
||||
"seat": "goldie",
|
||||
"op": "goldie-0325-start-1"
|
||||
},
|
||||
"note": null,
|
||||
"blockedReason": null,
|
||||
"createdAt": "2026-10-24T01:09:57.971Z",
|
||||
"updatedAt": "2026-10-24T01:22:42.822Z",
|
||||
"updatedBy": "gladstone"
|
||||
}
|
||||
],
|
||||
"log": [
|
||||
@@ -131766,6 +131856,256 @@
|
||||
"receipt": "ok duckworth-323-verdict-r1 rev 3778 row 323 round 1 approve by duckworth (comment 31477)"
|
||||
},
|
||||
"viewSha": "f776c94408564a716ca7efbc70ad058cb2b8704d4c8a6dfedb66beee4810f2c1"
|
||||
},
|
||||
{
|
||||
"rev": 3779,
|
||||
"op": "sage-3873",
|
||||
"verb": "note",
|
||||
"args": {
|
||||
"id": 323,
|
||||
"text": "LANDED 571376c30 + evidence 6a0f0f4f8 (ff from dd6cb5872), tip 99930362e. Duckworth approve c31477 rev 3778 on 6a0f0f4f8. Sage gate green 00:54Z on 6a0f0f4f8: all package suites 0 fail, every scripts/test-*.sh rc 0. Root cause of the revoke-window red: TMPDIR length, tools.sock over 107 bytes (session.mjs:278). Brief template rule and DEFERRED entry in c25799928 (mos-1561). N1/N2 doc only."
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-24T01:00:03.010Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 323,
|
||||
"field": "note",
|
||||
"receipt": "ok sage-3873 rev 3779 row 323 note"
|
||||
},
|
||||
"viewSha": "3bdae29d120d73111f7a31bd3a2457c9916b14ff609bff845d037b65da3cbce9"
|
||||
},
|
||||
{
|
||||
"rev": 3780,
|
||||
"op": "sage-3874",
|
||||
"verb": "move",
|
||||
"args": {
|
||||
"id": 323,
|
||||
"to": "done",
|
||||
"reason": null,
|
||||
"candidate": null,
|
||||
"evidence": null,
|
||||
"issue": null
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-24T01:00:54.851Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 323,
|
||||
"from": "in-review",
|
||||
"to": "done",
|
||||
"round": 1,
|
||||
"issue": null,
|
||||
"candidate": null,
|
||||
"receipt": "ok sage-3874 rev 3780 row 323 in-review→done round 1"
|
||||
},
|
||||
"viewSha": "f3418b4c61363a419ac03600b3feb6cc9a4bbce4405e8980bf2787801672809f"
|
||||
},
|
||||
{
|
||||
"rev": 3781,
|
||||
"op": "sage-3879",
|
||||
"verb": "add",
|
||||
"args": {
|
||||
"piece": "Case 1 business file: re-key project to stack to match .mosaic/project.json, plus a project-file test (#414 stage 2, ops-0288, mos-1563)",
|
||||
"gate": "New test file and packages/business whole on CLEAR; one key mutant killed; Gladstone review; Sage full landing gate (mos-1455)",
|
||||
"brief": "docs/plans/2026-10-24_case1-project-key-brief.md#Brief: re-key the Case 1 project to stack",
|
||||
"issues": [
|
||||
1821
|
||||
],
|
||||
"note": null,
|
||||
"owner": "goldie",
|
||||
"gateOwner": "sage",
|
||||
"after": null,
|
||||
"reviewers": [
|
||||
"gladstone"
|
||||
],
|
||||
"required": null
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-24T01:09:57.971Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 325,
|
||||
"from": null,
|
||||
"to": "queued",
|
||||
"brief": {
|
||||
"path": "docs/plans/2026-10-24_case1-project-key-brief.md",
|
||||
"anchor": "Brief: re-key the Case 1 project to stack",
|
||||
"blob": "8d91484c6731c7f7ceaf197a483c95024dee7448"
|
||||
},
|
||||
"receipt": "ok sage-3879 rev 3781 row 325 none→queued"
|
||||
},
|
||||
"viewSha": "c59e1401c4689a96fa467c787bd9691b729eda3e2af4679188235eeb6d233094"
|
||||
},
|
||||
{
|
||||
"rev": 3782,
|
||||
"op": "sage-3880",
|
||||
"verb": "move",
|
||||
"args": {
|
||||
"id": 325,
|
||||
"to": "briefed",
|
||||
"reason": null,
|
||||
"candidate": null,
|
||||
"evidence": null,
|
||||
"issue": null
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-24T01:10:53.568Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 325,
|
||||
"from": "queued",
|
||||
"to": "briefed",
|
||||
"round": null,
|
||||
"issue": null,
|
||||
"candidate": null,
|
||||
"receipt": "ok sage-3880 rev 3782 row 325 queued→briefed"
|
||||
},
|
||||
"viewSha": "41afd165d5a71f6af386de17bf8f6e9c6fe107dbe99956b5f7a86731fa361228"
|
||||
},
|
||||
{
|
||||
"rev": 3783,
|
||||
"op": "goldie-0325-start-1",
|
||||
"verb": "move",
|
||||
"args": {
|
||||
"id": 325,
|
||||
"to": "in-progress",
|
||||
"reason": null,
|
||||
"candidate": null,
|
||||
"evidence": null,
|
||||
"issue": null
|
||||
},
|
||||
"by": "goldie",
|
||||
"at": "2026-10-24T01:16:58.930Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 325,
|
||||
"from": "briefed",
|
||||
"to": "in-progress",
|
||||
"round": null,
|
||||
"issue": null,
|
||||
"candidate": null,
|
||||
"receipt": "ok goldie-0325-start-1 rev 3783 row 325 briefed→in-progress"
|
||||
},
|
||||
"viewSha": "d768e4bf91d9908b5697649267a4393f7c4c7a9db0d05ec377906e318d522d87"
|
||||
},
|
||||
{
|
||||
"rev": 3784,
|
||||
"op": "goldie-0325-review-1",
|
||||
"verb": "move",
|
||||
"args": {
|
||||
"id": 325,
|
||||
"to": "in-review",
|
||||
"reason": null,
|
||||
"candidate": "c5f96836d5ffa6cc0160da7360321706bef86a17",
|
||||
"evidence": null,
|
||||
"issue": null
|
||||
},
|
||||
"by": "goldie",
|
||||
"at": "2026-10-24T01:18:04.364Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 325,
|
||||
"from": "in-progress",
|
||||
"to": "in-review",
|
||||
"round": 1,
|
||||
"issue": 1821,
|
||||
"candidate": {
|
||||
"kind": "commit",
|
||||
"digest": "c5f96836d5ffa6cc0160da7360321706bef86a17",
|
||||
"text": null
|
||||
},
|
||||
"receipt": "ok goldie-0325-review-1 rev 3784 row 325 in-progress→in-review round 1 on #1821; request goldie-0325-review-1 requesting"
|
||||
},
|
||||
"viewSha": "23408b96e46a3d6f5f80470448c475622261ebc99fa6ed3cd3d0339e79f4e69b"
|
||||
},
|
||||
{
|
||||
"rev": 3785,
|
||||
"op": "goldie-0325-review-1.outcome",
|
||||
"verb": "review-outcome",
|
||||
"args": {
|
||||
"id": 325,
|
||||
"attempt": "goldie-0325-review-1",
|
||||
"outcome": "failed",
|
||||
"status": null,
|
||||
"comment": null,
|
||||
"detail": "pre-send: the credential or account check failed"
|
||||
},
|
||||
"by": "goldie",
|
||||
"at": "2026-10-24T01:19:00.773Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 325,
|
||||
"round": 1,
|
||||
"attempt": "goldie-0325-review-1",
|
||||
"state": "failed",
|
||||
"receipt": "ok goldie-0325-review-1.outcome rev 3785 row 325 round 1 request goldie-0325-review-1 failed"
|
||||
},
|
||||
"viewSha": "a098b29709b88484d98350f44750a37a621751a982c47fe0863ae9b6c938d224"
|
||||
},
|
||||
{
|
||||
"rev": 3786,
|
||||
"op": "sage-3884",
|
||||
"verb": "review-request",
|
||||
"args": {
|
||||
"id": 325
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-24T01:20:34.463Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 325,
|
||||
"round": 1,
|
||||
"attempt": "sage-3884",
|
||||
"state": "requesting",
|
||||
"receipt": "ok sage-3884 rev 3786 row 325 round 1 request sage-3884 requesting"
|
||||
},
|
||||
"viewSha": "44e292c9544263d2093cacc4f643bf452c9bdd21c72c64d0ac5a169cb8d900e8"
|
||||
},
|
||||
{
|
||||
"rev": 3787,
|
||||
"op": "sage-3884.outcome",
|
||||
"verb": "review-outcome",
|
||||
"args": {
|
||||
"id": 325,
|
||||
"attempt": "sage-3884",
|
||||
"outcome": "posted",
|
||||
"status": 201,
|
||||
"comment": 31482,
|
||||
"detail": "created"
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-24T01:21:30.422Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 325,
|
||||
"round": 1,
|
||||
"attempt": "sage-3884",
|
||||
"state": "posted",
|
||||
"receipt": "ok sage-3884.outcome rev 3787 row 325 round 1 request sage-3884 posted comment 31482"
|
||||
},
|
||||
"viewSha": "25a06deff4a77b1cf1a889e39edfbdc2e73a9dd3f93653e4b91b0e6400549348"
|
||||
},
|
||||
{
|
||||
"rev": 3788,
|
||||
"op": "gladstone-325-verdict-r1",
|
||||
"verb": "review-record",
|
||||
"args": {
|
||||
"id": 325,
|
||||
"verdict": "approve",
|
||||
"comment": 31484,
|
||||
"candidate": "c5f96836d5ffa6cc0160da7360321706bef86a17"
|
||||
},
|
||||
"by": "gladstone",
|
||||
"at": "2026-10-24T01:22:42.822Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 325,
|
||||
"round": 1,
|
||||
"verdict": "approve",
|
||||
"receipt": "ok gladstone-325-verdict-r1 rev 3788 row 325 round 1 approve by gladstone (comment 31484)"
|
||||
},
|
||||
"viewSha": "422f9329c44229356521749dc31d56b1130fbee51659c18a58657003cf4880a5"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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?
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user