row 272: preserve granted-user default chat on denied session catalog
This commit is contained in:
1 parent
352aa5dc07
commit
b44332a936
10 files changed
+341
-7
No files matched your search
@@ -5395,3 +5395,5 @@ Row 299 (573, #1806, Gander, Filbert): before, the P10 band test timed a fault r
|
||||
2026-10-23 | Huey | Row 272 sage-3145 preservation correction. refs/stash is shared, so it was unsafe as sole draft storage. Pinned exact stash commit 07dc9d2f7944b4781b5e80dfa18425f4749f7a45 to local huey/row272-stash-keep and left the entry untouched. Sage explicitly directed committing the rebased draft before testing to preserve it: an untested local checkpoint, no acceptance claim. No Node/test/queue/network/live action. Separate claims and test CLEARs remain pending; old-test delta is with Mos via sage-3144.
|
||||
|
||||
2026-10-23 | Huey | Row 272 early static review follow-up, scrooge-272-early-read-huey-1. Before: a bad-session 404 erased sibling draft/recovery caches, malformed clear/cancel write receipts omitted the audit line, and another session's op key produced an ambiguous error. Draft after: purge only on named thread-level refusals, write one redacted malformed-receipt audit line, and identify key reuse without a write. New browser/HTTP/CLI tests authored, unrun under test hold; approved old-test hunks unchanged. Published 850b22680/round-1 manifest unchanged; follow-up is uncommitted, no acceptance claim.
|
||||
|
||||
2026-10-23 | Huey | Row 272 round 2 on row 219 landing 5fdfd7275, after 295. Replayed round 1 and B1/N1/N2; seven conflicts resolved retaining both log histories, both test sets, 219 instance chooser and 295 owner-settings-only transport. Static comparison confirms old tests differ only by mos-1388 approved hunks. Sage3357/Mos1442 authorize bounded granted-user legacy fallback: only session.list access-refused and omitted-session route; no new session grants or owner routing. RED first under3358 on new file only: 1 expected legacy assertion failure, 3 guards pass, 3.504s, TMPDIR mktemp/no underscore, no compose network; slot returned as huey0035. Then implemented fallback and added timeout/bus/malformed guards plus isolated-copy mutant runner, all unrun afterward. Candidate commit/push precedes Sage's full gate by3354; no green claim.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Row 272 / 264e (#1777): session CLI and Console chooser
|
||||
|
||||
Owner Huey; reviewer Scrooge per sage-2816 / mos-1310 item 2 (supersedes sage-2796). Current base b23783f39, branch huey/row272-b23783f39 (sage-3141). Original draft base 2d0d407ba on huey/row272.
|
||||
Owner Huey; reviewer Scrooge per sage-2816 / mos-1310 item 2 (supersedes sage-2796). Current base 5fdfd7275, branch huey/row272-5fdfd7275 (sage-3354). Original draft base 2d0d407ba; round 1 was 850b22680 on b23783f39.
|
||||
Brief: docs/plans/2026-10-21_t1-sessions.md (ff0be0c79), section 8; row 264 rulings
|
||||
at 5f5b51dd6. Dependencies: 264a-d landed; 270/271 landing is 7328da8ca.
|
||||
Count/clear implementation and contracts are now in the current base. No runtime, launcher, library adapter,
|
||||
@@ -183,3 +183,88 @@ Execution watch: inspect message-filter.test first if the bus suite reports a B6
|
||||
fixture red (manual message_threads rows must name the run's bound session); do not
|
||||
change an old fixture/assertion without Mos's ruling via Sage. thread-shares uses the
|
||||
persisted session_defaults mapping per Scrooge's check. No runtime verdict claimed.
|
||||
|
||||
## Granted-user legacy compatibility (sage-3357; pre-candidate design)
|
||||
|
||||
219/295 are retained on base 5fdfd7275. USER_VERBS still excludes session verbs;
|
||||
193/T5 user session grants are a separate DEFERRED item, outside 272. No owner
|
||||
transport carries a user session operation. Bounded negotiation applies only to the
|
||||
session.list read: only `access-refused` and an omitted-session route permit legacy
|
||||
thread-default read/send/open. All other errors keep the unavailable/error state, and
|
||||
explicit session links always refuse. On fallback session controls are hidden and one
|
||||
status line says sessions are not available for this account. No fake session ID,
|
||||
count or lifecycle status is invented; the broker continues choosing its persisted
|
||||
default under current grants on every existing thread request. Owner routes keep the
|
||||
normal chooser and explicit selected-session arguments.
|
||||
|
||||
New file chat-sessions-access.test.mjs uses the real broker with an already granted
|
||||
principal over loopback and the existing repository browser fixture. It checks legacy
|
||||
read/send/open with no session controls, explicit-link refusal, another-error refusal,
|
||||
and unchanged owner chooser. No shared stub or existing assertion changes. The three
|
||||
required mutants remove the access-refused check, remove the omitted-session check,
|
||||
and display the controls during fallback; guard tests must kill each by assertion.
|
||||
|
||||
RED-first slot sage-3358: only this new file, before fallback implementation. Expected
|
||||
run 100 s including four Chromium fixtures; bound 300 s (3x), command
|
||||
`timeout -k 10 300 node --test packages/webui/tests/chat-sessions-access.test.mjs`.
|
||||
TMPDIR must be assigned from mktemp -d, with no underscore. No compose service is used;
|
||||
check/remove the generated directory's compose default network if any exists. Expected
|
||||
legacy-positive red is placed/reported afterward under mos-1272. Any other red is
|
||||
reported to Sage before a fix. No other test file/suite is authorized in this slot.
|
||||
|
||||
sage-3358 RED run completed before the fallback implementation. Only the new
|
||||
chat-sessions-access.test.mjs ran, under the 300 s bound, TMPDIR allocated by
|
||||
`mktemp -d /tmp/huey272-red-XXXXXXXX` as /tmp/huey272-red-0q66Jcf4. Exit 1;
|
||||
4 tests, expected legacy-positive assertion red and 3 passes, no other reds/skips/
|
||||
cancellations/timeouts, 3.504 s. Exact output: r2-red-access.tap. No matching compose
|
||||
network existed. Slot returned to Sage/Mos as huey-0035; no further tests run.
|
||||
|
||||
mos-1442 / sage-3361 adds explicit timeout, bus-error and malformed-answer refusal
|
||||
coverage. The new private fixture adds those guard cases (unrun), with a widening-key
|
||||
mutant alongside the access-refused/omitted-selector/control mutants. No shared stub
|
||||
or existing assertion changed. Native preview status/open both reported unavailable
|
||||
in this headless environment before the authorized repository-browser regression.
|
||||
|
||||
Implementation now local: the catalog request catches only access-refused on omitted
|
||||
session routes. Its internal null result selects the existing broker-chosen thread
|
||||
default with no fabricated session ID. Read/send/open omit the session property in
|
||||
that mode. One status line replaces all session controls. Explicit links and any
|
||||
other failure never select that mode. USER_VERBS and all owner transport routing
|
||||
remain byte-unchanged. Guard cases are preserved on the pre-fallback baseline; their
|
||||
RED evidence comes from the required mutants, as Sage accepted in sage-3358.
|
||||
|
||||
Fallback mutants are executable via fallback-mutants-r2.py, only on Sage's slot.
|
||||
It copies packages/roles into a private mktemp directory and mutates only the copied
|
||||
chat.js, never candidate bytes. Each run has a 300 s bound and requires a named
|
||||
assertion failure; timeout/import/setup failures do not count. F1 removes the exact
|
||||
access-refused key (human-required guard); F2 removes the omitted-selector check;
|
||||
F3 shows session controls; F4 widens the key to timeout/unreachable, no-bus-host and
|
||||
invalid-response (timeout guard). The test file also independently checks bus-error
|
||||
and malformed-answer refusal. These mutants and the post-implementation tests have
|
||||
not run locally. The earlier six non-fallback mutants in this packet remain required
|
||||
in the full gate; the new runner covers the four fallback mutants only.
|
||||
|
||||
|
||||
## Round 2 rebase receipt (sage-3354)
|
||||
|
||||
Queue rev 3510 returned row 272 to in-progress. On new branch huey/row272-5fdfd7275,
|
||||
replayed round 1 and the preserved B1/N1/N2 fixes onto 5fdfd7275 (219, after 295).
|
||||
Resolved exactly the seven named conflicts. Test append conflicts retain both complete
|
||||
sets of tests; existing test content equals the new base plus only the mos-1388 hunks.
|
||||
No old assertion or shared stub was changed beyond those approved hunks. Static byte
|
||||
comparison confirms the entire upstream test file prefixes and approved modifications.
|
||||
Both append-only logs retain the complete 5fdfd7275 prefix plus Huey's entries.
|
||||
|
||||
219's per-instance thread picker, explicit createThread instance forwarding, choice
|
||||
refresh/revocation behavior and tests remain present. 295's createChat owner-settings
|
||||
read stays unchanged: only plugins uses the owner transport; user chat verbs stay on
|
||||
the user transport. Removed a duplicate access-refused status entry introduced by
|
||||
combining those patches. USER_VERBS is unchanged. No schema or migration changed.
|
||||
|
||||
This is a pre-gate candidate per Sage's explicit instruction to commit/push/request
|
||||
round 2 before Sage runs the full gate. Only the expected-red new fallback test ran
|
||||
under sage-3358. No green test claim, no suite/mutant result and no old-file run is
|
||||
implied. Every touched test file still needs its whole-file run on Sage's slot, plus
|
||||
all touched suites and mutants. Any first-run red in launcher/creative/chat-browser
|
||||
returns to Mos via Sage before fixing. TMPDIR for gate runs comes only from mktemp -d,
|
||||
with no underscore; remove a matching compose default network only if created.
|
||||
@@ -0,0 +1 @@
|
||||
5fdfd7275ce3048e488d93dcdb1f9f2aa5961a69
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run only on Sage's slot. Mutate disposable sources, never the candidate tree."""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
GUARD = "if (err.code === 'access-refused' && !match[3]) return null;"
|
||||
CONTROLS = " if (current.legacyDefault) return '<p role=\"status\">Sessions are not available for this account.</p>';"
|
||||
MUTANTS = {
|
||||
'F1-any-code': (GUARD, "if (!match[3]) return null;", 'human-required catalog failure'),
|
||||
'F2-explicit-link': (GUARD, "if (err.code === 'access-refused') return null;", 'granted explicit-session route'),
|
||||
'F3-controls': (CONTROLS, ' // mutant: show session controls on a legacy fallback', 'granted legacy route'),
|
||||
'F4-widened-key': (GUARD, "if (['access-refused', 'unreachable', 'no-bus-host', 'invalid-response'].includes(err.code) && !match[3]) return null;", 'timeout catalog failure'),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument('--out', type=Path, required=True)
|
||||
ap.add_argument('--mutant', choices=list(MUTANTS), action='append')
|
||||
args = ap.parse_args()
|
||||
source = Path(__file__).resolve().parents[4]
|
||||
args.out.mkdir(parents=True, exist_ok=True)
|
||||
for name in args.mutant or MUTANTS:
|
||||
# Explicit mktemp, no underscores: same TMPDIR rule as the real gate.
|
||||
scratch = Path(subprocess.check_output(['mktemp', '-d', '/tmp/huey272-fallback-mutant-XXXXXXXX'], text=True).strip())
|
||||
try:
|
||||
for directory in ['packages', 'roles']:
|
||||
shutil.copytree(source / directory, scratch / directory)
|
||||
if (source / 'node_modules').exists():
|
||||
(scratch / 'node_modules').symlink_to((source / 'node_modules').resolve(), target_is_directory=True)
|
||||
for filename in ['package.json', 'RELEASE']:
|
||||
if (source / filename).exists():
|
||||
shutil.copy2(source / filename, scratch / filename)
|
||||
path = scratch / 'packages/webui/src/public/chat.js'
|
||||
old, new, target = MUTANTS[name]
|
||||
text = path.read_text()
|
||||
if text.count(old) != 1:
|
||||
raise RuntimeError(f'{name}: mutation anchor is not unique')
|
||||
path.write_text(text.replace(old, new, 1))
|
||||
env = dict(os.environ, TMPDIR=str(scratch))
|
||||
result = subprocess.run(['timeout', '-k', '10', '300', 'node', '--test', '--test-reporter=tap',
|
||||
'packages/webui/tests/chat-sessions-access.test.mjs'],
|
||||
cwd=scratch, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
(args.out / f'{name}.tap').write_text(result.stdout)
|
||||
killed = result.returncode == 1 and 'ERR_ASSERTION' in result.stdout and bool(
|
||||
re.search(r'not ok [^\n]*' + re.escape(target), result.stdout))
|
||||
print(json.dumps({'mutant': name, 'returncode': result.returncode, 'assertion_kill': killed}), flush=True)
|
||||
if not killed:
|
||||
raise RuntimeError(f'{name}: not an assertion kill; stop, diagnose and report before any fix')
|
||||
finally:
|
||||
# These tests never use compose. Remove the disposable copy after browser teardown;
|
||||
# retained TAP lives only in --out. No live network/volume/service cleanup is performed.
|
||||
shutil.rmtree(scratch)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,17 @@
|
||||
c21f1bd9af6841126baf19f5c9926ca23ba8e56b9af342373d8e4fefcee63632 packages/bus/src/broker.mjs
|
||||
3957243d59e50eaa2326b88b9205ed8e8b5d33bde2ce1ef62e68f20e56283568 packages/bus/tests/user-principal.test.mjs
|
||||
0a920131a981c2a1c9b73428a998966f446babae4dfe7d10eebdd6455571c0c9 packages/cli/README.md
|
||||
96c4f93628964b6ea10fb87d5cf936f9ecf2e7286aa20c0914ae8b0fb32c47e7 packages/cli/src/cli.mjs
|
||||
1a30048032f5bb64419ae9716a1692978bc1a137220a094d1900298aca6ba1d7 packages/cli/src/session.mjs
|
||||
58888b7731f8009d4c3f8be2caf4445f13dd8f534e1633f6e2720b259fcc3f92 packages/cli/src/thread.mjs
|
||||
359c9fbfe30d860046ee3d29d23681900d9c73b3a4c3a91cd2f4e1d2c6be143e packages/cli/tests/session-cli.test.mjs
|
||||
5e7e1ec169392ee3065f8f47d19732ebbe8bdd90f9193581425b956e66da6b73 packages/plugins/channel/cli/index.mjs
|
||||
aa086c365c4a6b0eaf84359a20ef2b30e723013ada6573fb0133163d5bf98a74 packages/plugins/channel/webui/index.mjs
|
||||
143bf601badf5cc2b25cb24368ace2ec34840abec858d272af239d6a4ddcdecb packages/plugins/tests/channel.test.mjs
|
||||
6e743878e507be20d87ae10afdb01c105f6f4fab76d02410e065f3f20d18e597 packages/webui/src/chat-http.mjs
|
||||
7a5a18a9879fe7132f20a651c6927b007475571bd8fc45314eda9d6809288cea packages/webui/src/chat.mjs
|
||||
88dd353f5df0603ed1fd43dd543ef7b45af93fee72b6bb6a03a26c068202fbd3 packages/webui/src/public/chat.css
|
||||
e4c25743a6d8cbe15eaac2b4d8c3bd309ffa5793f35584d862690a9d89725b72 packages/webui/src/public/chat.js
|
||||
1aa313f1602cf87a0ddac145bf58bbd7f1073c02009c65797e85418fdfdbd838 packages/webui/tests/chat-browser.test.mjs
|
||||
23de8678566f24a6c8bf0ca75b4550969015b06253586070e229f4eaf0bba2b4 packages/webui/tests/chat-sessions-access.test.mjs
|
||||
773538c0c6a26ab525aa30e588249ba4b4ee981129533db0348f2107da5dd1df packages/webui/tests/chat.test.mjs
|
||||
@@ -0,0 +1,32 @@
|
||||
✖ 272 fallback: granted legacy route keeps default history, send and open without session controls (967.088999ms)
|
||||
✔ 272 fallback guard: granted explicit-session route refuses without default history or writes (780.341865ms)
|
||||
✔ 272 fallback guard: another catalog error never enables default-thread access (765.314522ms)
|
||||
✔ 272 fallback guard: owner route retains chooser and explicit default-session addressing (901.74851ms)
|
||||
ℹ tests 4
|
||||
ℹ suites 0
|
||||
ℹ pass 3
|
||||
ℹ fail 1
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 3503.583341
|
||||
|
||||
✖ failing tests:
|
||||
|
||||
test at packages/webui/tests/chat-sessions-access.test.mjs:74:1
|
||||
✖ 272 fallback: granted legacy route keeps default history, send and open without session controls (967.088999ms)
|
||||
AssertionError [ERR_ASSERTION]: legacy granted-user thread must remain usable after session.list access-refused
|
||||
|
||||
false !== true
|
||||
|
||||
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/huey-system-manager-design/packages/webui/tests/chat-sessions-access.test.mjs:77:10)
|
||||
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
|
||||
at async Test.run (node:internal/test_runner/test:1409:7)
|
||||
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
|
||||
generatedMessage: false,
|
||||
code: 'ERR_ASSERTION',
|
||||
actual: false,
|
||||
expected: true,
|
||||
operator: 'strictEqual',
|
||||
diff: 'simple'
|
||||
}
|
||||
@@ -1579,3 +1579,4 @@ are never rewritten or removed; corrections are new entries.
|
||||
2026-10-23 | Huey (cabd45ca) | Row 272 sage-3141 local rebase | Draft reapplied from 2d0d407ba onto b23783f39 on huey/row272-b23783f39; product merge clean, append-only log conflicts retained both sides. Landed 270/271 contracts checked; no Node/tests/claim/commit/push pending CLEAR.
|
||||
2026-10-23 | Huey (cabd45ca) | Row 272 sage-3145 draft preservation | Shared stash commit 07dc9d2f7 pinned to huey/row272-stash-keep; rebased draft committed locally by explicit Sage instruction before tests. Untested checkpoint only; claims/test/push CLEARs still pending.
|
||||
2026-10-23 | Huey (cabd45ca) | Row 272 Scrooge early static read on 850b22680 | B1 and N1/N2 follow-up drafted with new tests only; old assertions/shared stub unchanged. Static diff clean; no tests/commit/push pending CLEAR, round 1 remains frozen.
|
||||
2026-10-23 | Huey (cabd45ca) | Row 272 round 2, sage-3354/3357/3358/3361 | Rebased onto 5fdfd7275 on huey/row272-5fdfd7275, preserving 219/295 and approved old-test hunks; rev3510 in-progress. New fallback test ran RED first in Sage's slot: expected legacy failure, 3 guards pass; slot returned. Bounded fallback and extra guards/mutants prepared for Sage's full gate; no post-implementation run or green claim.
|
||||
@@ -19,7 +19,7 @@ const STATUS = {
|
||||
'session-not-found': 404, 'operation-not-found': 404, 'session-exists': 409,
|
||||
'session-op-conflict': 409, 'session-clear-complete': 409, 'session-name-too-long': 409, 'session-clearing': 409, 'session-revision-stale': 409,
|
||||
'session-retired': 409, 'processing-halted': 409, 'confirm-required': 400,
|
||||
'unknown-verb': 503, 'channel-operation': 503, 'access-refused': 403,
|
||||
'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.
|
||||
'access-refused': 403, 'access-revoked': 401,
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
value.known === true && Number.isSafeInteger(value.count) && value.count >= 0 ? String(value.count) :
|
||||
`unknown (${value.reason ?? 'not-collected'})`;
|
||||
function sessionControls(t) {
|
||||
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);
|
||||
const reason = !Number.isSafeInteger(selected?.revision) ? 'Safe clear is unavailable until its dependency is installed.' : '';
|
||||
@@ -98,7 +99,7 @@
|
||||
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);
|
||||
if (t && c.session && !c.refused) html += conversation(t);
|
||||
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);
|
||||
@@ -143,7 +144,7 @@
|
||||
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, sessions: old?.id === id ? old.sessions ?? [] : [], session: match[3] ?? (old?.id === id ? old.session : null), key: match[3] ? id + '/' + match[3] : old?.id === id ? old.key : id };
|
||||
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?.id === id ? old.sessions ?? [] : [], session: match[3] ?? (old?.id === id ? old.session : null), key: match[3] ? id + '/' + match[3] : old?.id === id ? old.key : id };
|
||||
if (navigated || !old) container.innerHTML = '<p class="chat-loading" role="status">Reading chat…</p>';
|
||||
try {
|
||||
// Resolve the explicit session before touching history/cursors. Missing selectors never
|
||||
@@ -151,11 +152,24 @@
|
||||
const [projects, listed, catalog] = await Promise.all([
|
||||
request('projects', undefined, signal),
|
||||
request('threads' + (kind === 'project' ? '?project=' + id : ''), undefined, signal),
|
||||
kind === 'thread' ? request(`threads/${id}/sessions`, undefined, signal) : null,
|
||||
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,
|
||||
]);
|
||||
if (g !== generation || out()) return;
|
||||
current.projects = projects.rows; current.threads = listed.rows;
|
||||
let s = null, page = null, after = null;
|
||||
if (kind === 'thread' && catalog === null) {
|
||||
current.legacyDefault = true; current.sessions = []; current.session = null;
|
||||
current.key = id + '/legacy-default';
|
||||
s = state(current.key);
|
||||
after = s.loaded && (!s.more || next) ? s.after : null;
|
||||
page = await request(`threads/${id}/messages?limit=100${after ? '&after=' + after : ''}`, undefined, signal);
|
||||
if (g !== generation || out()) return;
|
||||
}
|
||||
if (catalog) {
|
||||
if (catalog.rows.some(r => !UUID.test(r.id) || typeof r.name !== 'string')) throw new ChatError('invalid-response');
|
||||
const selected = match[3] ? catalog.rows.find(r => r.id === match[3]) : catalog.rows.find(r => r.default === true);
|
||||
@@ -302,7 +316,7 @@
|
||||
event.preventDefault();
|
||||
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 }, `threads/${id}/messages`);
|
||||
void write('send', key, { body: state(key).draft, ...(session ? { session } : {}) }, `threads/${id}/messages`);
|
||||
} else if (event.target.id === 'chat-session-form') {
|
||||
const key = 'session:' + current.id, f = form(key);
|
||||
// Keep an ambiguous create's key and payload; editing requires a deliberate new attempt.
|
||||
@@ -322,7 +336,7 @@
|
||||
});
|
||||
document.addEventListener('click', event => {
|
||||
if (!current || out()) return;
|
||||
if (event.target.id === 'chat-resume') void write('open', current.key, { session: current.session }, `threads/${current.id}/open`);
|
||||
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();
|
||||
if (event.target.id === 'chat-next') void refresh(true);
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// Row 272, sage-3357: UI session negotiation over the real broker's account boundary.
|
||||
// The loopback fixture binds one already granted principal; login/CSRF are covered by
|
||||
// granted-user.test.mjs. No real account, provider, config or owner transport is used.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'node:http';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Broker } from '../../bus/src/broker.mjs';
|
||||
import { Store } from '../../bus/src/store.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
|
||||
async function setup(t, { owner = false, catalogError = null } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'huey-session-access-'));
|
||||
const store = new Store(root), calls = [];
|
||||
const access = { accessVersion:1, revision:1, users:[], grants:[{id:'g-1',user:'alice',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),'utf8')).messaging}},
|
||||
}}});
|
||||
const human = broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
const cap = owner ? human : broker.bindUser({business:'demo',user:'alice'});
|
||||
const invoke = (verb,args={}) => broker.request(cap,{verb,args});
|
||||
const project = broker.request(human,{verb:'project.create',args:{name:'shared'}}).id;
|
||||
const thread = invoke('thread.create',{project,name:'account thread',instance:'assistant'}).id;
|
||||
invoke('thread.send',{thread,body:'existing account history'});
|
||||
const session = store.get('SELECT session FROM session_defaults WHERE thread=? ORDER BY seq DESC LIMIT 1',thread).session;
|
||||
let b;
|
||||
const server = createServer(async (req,res) => {
|
||||
const url = new URL(req.url,'http://fixture');
|
||||
const json = (status,value) => {res.writeHead(status,{'content-type':'application/json','cache-control':'no-store'});res.end(JSON.stringify(value));};
|
||||
if(url.pathname === '/') {res.writeHead(200,{'content-type':'text/html'});res.end('<!doctype html><title>Session access fixture</title><div id="view"></div><script src="/chat.js"></script>');return;}
|
||||
if(url.pathname === '/chat.js') {res.writeHead(200,{'content-type':'text/javascript'});res.end(readFileSync(new URL('../src/public/chat.js',import.meta.url)));return;}
|
||||
try {
|
||||
let verb,args={};
|
||||
const match=/^\/api\/chat\/threads\/([^/]+)\/(sessions|messages|open)$/.exec(url.pathname);
|
||||
if(url.pathname === '/api/chat/projects') verb='project.list';
|
||||
else if(url.pathname === '/api/chat/threads') verb='thread.list';
|
||||
else if(match) {
|
||||
args.thread=match[1];
|
||||
if(match[2] === 'sessions') verb='session.list';
|
||||
else if(match[2] === 'open') verb='thread.open';
|
||||
else verb=req.method === 'POST' ? 'thread.send' : 'thread.messages';
|
||||
if(req.method === 'POST') {
|
||||
let body='';for await(const part of req)body+=part;
|
||||
Object.assign(args,JSON.parse(body));
|
||||
if(verb === 'thread.send')args.class='REQUEST';
|
||||
} else if(verb === 'thread.messages') {
|
||||
args.limit=Number(url.searchParams.get('limit')??100);
|
||||
for(const key of ['session','after'])if(url.searchParams.has(key))args[key]=url.searchParams.get(key);
|
||||
}
|
||||
} else return json(404,{error:'not-found'});
|
||||
calls.push({verb,args:structuredClone(args),owner});
|
||||
if(verb === 'session.list' && catalogError === 'malformed')return json(200,{rows:{invalid:true}});
|
||||
if(verb === 'session.list' && catalogError)throw Object.assign(new Error(catalogError),{code:catalogError});
|
||||
const value=invoke(verb,args);
|
||||
if(req.method === 'POST')return json(200,value);
|
||||
return json(200,{rows:value,after:verb==='thread.messages'?value.at(-1)?.id??null:null,hasMore:false});
|
||||
} catch(e) {json(['access-refused','human-required'].includes(e.code)?403:503,{error:e.code??'invalid-response',sent:false});}
|
||||
});
|
||||
t.after(async()=>{await b?.close();await new Promise(r=>server.close(r));store.close();rmSync(root,{recursive:true,force:true});});
|
||||
await new Promise(r=>server.listen(0,'127.0.0.1',r));
|
||||
b=await browser();
|
||||
if(catalogError === 'timeout')await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`{const actual=fetch.bind(window);window.fetch=(url,opts)=>String(url).endsWith('/sessions')?Promise.reject(new DOMException('fixture transport timeout','TimeoutError')):actual(url,opts);}`});
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/`);
|
||||
const render = async explicit => {
|
||||
const path=`/chat/threads/${thread}${explicit?`/sessions/${session}`:''}`;
|
||||
await b.evaluate(`(async()=>{location.hash=${JSON.stringify('#'+path)};await window.MosaicChat.render({path:${JSON.stringify(path)},container:document.getElementById('view'),canSend:true,navigated:true});return true;})()`);
|
||||
};
|
||||
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,30));}return false;})()`);
|
||||
return {b,calls,render,wait,thread,session,store};
|
||||
}
|
||||
|
||||
test('272 fallback: granted legacy route keeps default history, send and open without session controls', async t => {
|
||||
const {b,calls,render,wait,thread,store}=await setup(t);
|
||||
await render(false);
|
||||
assert.equal(await b.evaluate('!!document.querySelector("#chat-send-form")'),true,'legacy granted-user thread must remain usable after session.list access-refused');
|
||||
assert.match(await b.evaluate('document.querySelector(".chat-log").textContent'),/existing account history/);
|
||||
assert.equal(await b.evaluate('!!document.querySelector(".chat-session-controls")'),false);
|
||||
assert.match(await b.evaluate('document.getElementById("view").textContent'),/sessions.*not.*available.*account/i);
|
||||
await b.evaluate('(()=>{const input=document.getElementById("chat-body");input.value="new granted message";input.dispatchEvent(new Event("input",{bubbles:true}));document.querySelector("#chat-send-form button").click();return true;})()');
|
||||
assert.equal(await wait('document.getElementById("chat-receipt").textContent.includes("Accepted")'),true);
|
||||
assert.equal(store.get("SELECT count(*) n FROM messages WHERE body='new granted message'").n,1);
|
||||
const send=calls.find(c=>c.verb==='thread.send');
|
||||
assert.equal(send.args.thread,thread);assert.equal(Object.hasOwn(send.args,'session'),false);
|
||||
await b.evaluate('document.getElementById("chat-resume").click(),true');
|
||||
assert.equal(await wait('document.getElementById("chat-receipt").textContent.includes("resume requested")'),true);
|
||||
assert.deepEqual(calls.find(c=>c.verb==='thread.open').args,{thread});
|
||||
assert.ok(calls.some(c=>c.verb==='session.list'));
|
||||
assert.ok(calls.filter(c=>c.verb==='thread.messages').every(c=>!Object.hasOwn(c.args,'session')));
|
||||
assert.ok(calls.every(c=>c.owner===false),'no request may switch to owner authority');
|
||||
});
|
||||
|
||||
test('272 fallback guard: granted explicit-session route refuses without default history or writes', async t => {
|
||||
const {b,calls,render}=await setup(t);
|
||||
await render(true);
|
||||
assert.equal(await b.evaluate('!!document.querySelector("#chat-send-form")'),false);
|
||||
assert.equal(await b.evaluate('!!document.querySelector(".chat-session-controls")'),false);
|
||||
assert.equal(calls.some(c=>['thread.messages','thread.send','thread.open'].includes(c.verb)),false);
|
||||
});
|
||||
|
||||
for(const failure of ['human-required','timeout','no-bus-host','malformed']) {
|
||||
test(`272 fallback guard: ${failure} catalog failure never enables default-thread access`, async t => {
|
||||
const {b,calls,render}=await setup(t,{catalogError:failure});
|
||||
await render(false);
|
||||
assert.equal(await b.evaluate('!!document.querySelector("#chat-send-form")'),false);
|
||||
assert.equal(calls.some(c=>c.verb==='thread.messages'),false);
|
||||
});
|
||||
}
|
||||
|
||||
test('272 fallback guard: owner route retains chooser and explicit default-session addressing', async t => {
|
||||
const {b,calls,render,session}=await setup(t,{owner:true});
|
||||
await render(false);
|
||||
assert.equal(await b.evaluate('!!document.querySelector(".chat-session-controls")'),true);
|
||||
assert.equal(await b.evaluate('document.getElementById("chat-session").value'),session);
|
||||
assert.equal(calls.find(c=>c.verb==='thread.messages').args.session,session);
|
||||
assert.match(await b.evaluate('document.querySelector(".chat-log").textContent'),/existing account history/);
|
||||
});
|
||||
Reference in new issue
Block a user