From 8c3f48113fcbfca11c30d57bc955dd0ad6cb2a41 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Fri, 6 Nov 2026 04:48:14 -0600 Subject: [PATCH] fix(webui): restrict writes when archive state is unknown --- BUILD-LOG.md | 2 + docs/SESSIONS.md | 1 + packages/webui/src/public/chat.js | 37 ++++++--- packages/webui/src/public/requests.js | 25 +++--- packages/webui/src/request-http.mjs | 2 + .../tests/archive-unknown-browser.test.mjs | 73 +++++++++++++++++ .../webui/tests/helpers/archive-origin.mjs | 80 +++++++++++++++++++ packages/webui/tests/helpers/w15-requests.mjs | 25 +++--- packages/webui/tests/request-http.test.mjs | 8 ++ 9 files changed, 218 insertions(+), 35 deletions(-) create mode 100644 packages/webui/tests/helpers/archive-origin.mjs diff --git a/BUILD-LOG.md b/BUILD-LOG.md index 51f981916..4af7892f6 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -6287,3 +6287,5 @@ R5 quote fallback room (#797 D3, 2026-11-06, MVP3 lead): r4 review P2 found that 2026-11-06 | #797 MVP3 project archive round 3 — implementation and verification | Before: r2 head 1c33c342b had two P1s: stale final launch admission and an omitted origin-chat binding downstream. After: a single broker snapshot fences thread and manifest at launch; shared complete request-binding checks fence bootstrap effects, PRD activation and task/requirements admission while preserving accepted receipts and reads. Added permanent race/origin/shared-repository regressions and operator guidance. Node 26: exact-parent negative 5 behavioral failures / 1 control pass; final regressions 6/6, bus excluding socket file 1614/1614, WebUI 583/583, corrected whole release file 226/226. The separate project-files socket test timed out after five passes (exit 124, one cancelled wrapper); C8 had 569/587 after fixture timeouts were fixed, with 17 release-fixture failures subsequently corrected and the known parent adapter-exited-127 failure remaining. Initial failures, full commands and comparisons are retained in /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r3-evidence/ and the report /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r3-report.md. No schema/store, supervisor, role, contract or policy changes; no push, merge, deployment or live acceptance. Independent limited re-review remains. 2026-11-06 | #797 MVP3 project archive r4 — integration and author verification | Before: r3 f23a998ee and integration 5255e4e74 conflicted; the reviewed resolution tree reproduced WebUI 625 pass / 15 fail. After: exact reviewed merge 6a655a5cb, archive route fix and readable unknown archive state with real broker refusal evidence. Node 26 final WebUI 644/644, bus excluding project-files 1642/1642, harness 277/277, CLI whole-file partitions 1198 pass / 1 FED R7 skip. Candidate and integration parent project-files each timed out after five passes with three socket cases unfinished; this is not a green whole-bus gate. Earlier focus and Claude row 66 failures, clean reruns, comparisons and exact commands are retained in /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r4-report.md and project-archive-r4-evidence/. Local commits only; independent delta review remains, no push or deployment. + +2026-11-06 | #797 MVP3 project archive r5 — WebUI implementation | Before: r4 head 37f38d8e1 exposed unguarded request and session writes when archive lookup failed, and mapped guarded attach's project-archived refusal to 503 outcome-unknown. After: the unknown-state thread keeps reads and guarded writes available with a warning, suppresses unguarded controls and handlers including library revert, and reports guarded attach as 409/refused; live and archived recovery are tested. Node 26: final WebUI 649 pass / 0 fail / 0 cancelled / 0 skipped; exact-r4 negative 5 behavioral failures, real authenticated archived-origin attach 409. An initial diagnostic full run had 647 pass / 2 fail while source was changing (session catalog recovery and known-archived clear cancellation); both pass on exact r4 and after correction. All iterations, comparisons, inventory and commands: /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r5-report.md and project-archive-r5-evidence/. Bus/harness/CLI not run for the WebUI-only change. Queue refused this linked worktree; no queue move, push, merge or deployment. Local implementation candidate for independent review. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index a28d638fe..afa6a73d6 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -2091,3 +2091,4 @@ are never rewritten or removed; corrections are new entries. 2026-11-06 | mvp3-archive-r2-child (#797 lane) | feat/mvp3-project-archive-r2 from 3e21d239b: close archived thread/session and product/request/PRD/task/bootstrap/manifest admissions; preserve accepted completion, cleanup and reads; repair archived navigation and two-hook styling | bus 1618/1618, WebUI 583/583, other consumers 230/230, lifecycle/launch 67/67; CLI path run 585/587 with both dependency-fixture failures reproduced then superseded by whole-file case2-install 28/28 after self-link cleanup. Schema stays 3t; budget-supervisor integration fix deferred. Exact runs/failures and handoff: /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r2-report.md. Local commit only; independent review pending; no push, merge, deployment or fleet action. 2026-11-06 | mvp3-archive-r3-implementer (#797 lane) | feat/mvp3-project-archive-r3 from 1c33c342b: fix final launch admission and preserve originating chat through bootstrap, PRD and task admissions; shared-repository note and regressions | Node 26: regressions 6/6, bus excluding socket 1614/1614, WebUI 583/583, corrected whole release file 226/226; socket timeout and known adapter-127 failure remain ungreen, with all intermediate failures retained in /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r3-report.md. Local commit only; limited independent review pending; no push, merge or deployment. 2026-11-06 | Codex implementation sub-agent | #797 MVP3 project archive r4 | Applied reviewed merge 6a655a5cb and two WebUI fixes; Node 26 WebUI 644/644, bus without socket file 1642/1642, harness 277/277, CLI 1198 pass / 1 skip. Socket timeout reproduced on 5255e4e; original failures and comparisons in /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r4-report.md. Local candidate for independent delta review; no push or deployment. +2026-11-06 | Codex implementation sub-agent | #797 project archive r5 WebUI fixes on 37f38d8e1 | Unknown archive state suppresses unguarded writes, guarded attach maps to 409; final Node 26 WebUI 649/0/0/0, five exact-baseline behavioral negatives, real authenticated attach refusal. Initial diagnostic regressions corrected; inventory, all evidence and limits in /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r5-report.md. Local candidate for independent review; queue refused linked worktree; no push, merge or deployment. diff --git a/packages/webui/src/public/chat.js b/packages/webui/src/public/chat.js index d3fe4774e..1bc104e21 100644 --- a/packages/webui/src/public/chat.js +++ b/packages/webui/src/public/chat.js @@ -82,7 +82,7 @@ const threadAccess = () => !!current.thread && !current.shared && scopeWrites(current.thread?.project, current.thread?.instance); const threadWrites = () => !current.projectArchived && threadAccess(); function sharing(t) { - if (!current.canSend || !threadWrites() || current.tester) return ''; + if (!current.canSend || !threadWrites() || current.archiveUnknown || current.tester) return ''; const f = form('share:' + t.id); return `

Share read-only

Share with a user (user:id), the business owner's name, or * for everyone granted on this thread.

${txt(f.receipt)}

`; } @@ -109,7 +109,7 @@ 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.' : ''; - return `

Compactions: ${txt(countLabel(selected?.compactions))}

${txt(selected?.state ?? 'Session status unavailable')}${UUID.test(selected?.successor) ? ` · Open fresh successor` : ''}

${txt(f.receipt)}

${reason ? `

${txt(reason)}

` : ''}

${txt(clear.receipt)}

`; + return `

Compactions: ${txt(countLabel(selected?.compactions))}

${txt(selected?.state ?? 'Session status unavailable')}${UUID.test(selected?.successor) ? ` · Open fresh successor` : ''}

${txt(f.receipt)}

${reason ? `

${txt(reason)}

` : ''}

${txt(clear.receipt)}

`; } // A cite is changed when the file's last commit at the served tip (`last`, read with the page) is not // its last commit at the cited one (`file`, stored with the check). Either one missing marks nothing. @@ -125,7 +125,7 @@ if (r?.kind === 'reply') return `
${txt(r.text)}
${r.citations.length ? cites(r.citations) : ''}`; if (r?.kind === 'notice') { const v = reverts.get(r.commit) ?? {}; - return `
${txt(r.text)} ${txt(v.receipt ?? '')}
`; + return `
${txt(r.text)} ${current.archiveUnknown ? '' : ``} ${txt(v.receipt ?? '')}
`; } return `
${txt(m.body)}
`; } @@ -177,7 +177,7 @@ async function revert(commit) { const c = current, t = c?.threads.find(t => t.id === c.id), v = reverts.get(commit) ?? {}; const project = libraryOf(c, t); - if (!c || !c.canSend || out() || v.pending || !SHA.test(commit) || project === null) return; + if (!c || c.archiveUnknown || !c.canSend || out() || v.pending || !SHA.test(commit) || project === null) return; if (!window.confirm(`Revert ${commit.slice(0, 7)}? This makes a new commit in the library that puts the file back as it was before it.`)) return; const unknown = 'Outcome unknown. Read the library status before trying again; nothing was retried.'; reverts.set(commit, { pending: true, receipt: 'Reverting…' }); paint(); @@ -398,7 +398,8 @@ const keep = capture(), c = current; let html = '

Projects

'; if (c.error) html += ``; - if (c.archiveUnknown) html += '

Archive status unavailable. Sending may be refused; refresh to check.

'; + if (c.archiveUnknown) html += '

Archive status unavailable. Some changes are unavailable; sending may be refused; refresh to check.

'; + else if (c.kind === 'thread' && c.projectArchived) html += '

This project is archived; its conversation stays readable.

'; if (c.readFailed && (c.projects.length || c.threads.length || c.kind === 'thread' && state(c.key).loaded)) html += '

Showing previously read chat content; it may be out of date.

'; if (c.kind === 'room') { html += c.refused ? heading('Room unavailable') : roomConversation(); @@ -419,7 +420,7 @@ } if (!c.canSend) html += '

Creating and sending require signed-in send mode. This Console only reads.

'; c.container.innerHTML = html + '
'; - window.MosaicRequests?.mount?.(c.container); + window.MosaicRequests?.mount?.(c.container, { archiveUnknown: !!c.archiveUnknown }); const t = c.kind === 'thread' ? c.thread : null; const repo = c.kind === 'project' ? c.projects.find(p => p.id === c.id)?.repo : libraryOf(c, t); const cached = knownRequests.get(c.id); @@ -480,8 +481,18 @@ paint(); return; } const old = current; - current = { container, kind, id, settings: !!settings, path: originalPath, 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: '', readFailed: false, channelDisabled: old?.channelDisabled ?? false, refused: false, legacyDefault: old && old.id === id ? old.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, archiveUnknown: kind === 'thread' && !user && !tester, settings: !!settings, path: originalPath, 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: '', readFailed: false, channelDisabled: old?.channelDisabled ?? false, refused: false, legacyDefault: old && old.id === id ? old.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 = '

Reading chat…

'; + else if (current.archiveUnknown) { + // Keep the previous conversation in place while its archive lookup is pending, + // but immediately withdraw controls whose server paths lack archive guards. + for (const el of container.querySelectorAll('#chat-session-form input, #chat-session-form button, #chat-share-form input, #chat-share-form button, [data-unshare], #chat-cancel, [data-revert]')) el.disabled = true; + window.MosaicRequests?.mount?.(container, { archiveUnknown: true }); + const notice = document.createElement('p'); + notice.setAttribute('role', 'status'); + notice.textContent = 'Archive status unavailable. Some changes are unavailable; sending may be refused; refresh to check.'; + container.querySelector('.chat-area')?.prepend(notice); + } try { if (user) { const config = await fetch('/api/config', { cache: 'no-store', redirect: 'manual', signal }); @@ -512,9 +523,11 @@ const projectId = kind === 'project' ? id : current.thread?.project; if (projectId && !current.projects.some(p => p.id === projectId) && !user && !tester) { try { - const resolved = await window.MosaicProjectArchive?.resolve(projectId, signal); + if (typeof window.MosaicProjectArchive?.resolve !== 'function') throw Error('archive-unavailable'); + const resolved = await window.MosaicProjectArchive.resolve(projectId, signal); if (g !== generation || out()) return; if (resolved) current.projects = [...current.projects, resolved]; + current.archiveUnknown = false; } catch (err) { if (kind !== 'thread' || err.name === 'AbortError') throw err; if (g !== generation || out()) return; @@ -523,6 +536,7 @@ current.archiveUnknown = true; } } + else if (projectId) current.archiveUnknown = false; current.projectArchived = current.projects.some(p => p.id === projectId && p.archived === 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; @@ -653,7 +667,7 @@ document.addEventListener('input', event => { if (!current || out()) return; if (event.target.id === 'chat-room-body') { state(current.key).draft = event.target.value; updateRoomCompletion(); 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-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 || current.archiveUnknown || 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'); @@ -673,6 +687,7 @@ if (!current || !current.canSend || out() || current.channelDisabled) return; const isThread = action === 'send' || action === 'open'; if ((isThread || action === 'createSession') && !threadWrites()) return; + if (action === 'createSession' && current.archiveUnknown) 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); @@ -777,7 +792,7 @@ } }); async function changeShare(op, to) { - if (!current?.canSend || !threadWrites() || current.tester || out()) return; + if (!current?.canSend || !threadWrites() || current.archiveUnknown || current.tester || out()) return; const id = current.id, f = form('share:' + id); if (f.pending || !to.trim()) return; f.pending = true; f.receipt = ''; paint(); @@ -807,7 +822,7 @@ } async function cancelClear() { const c = current, key = c.key, f = form('clear:' + key); - if (!c.canSend || !threadAccess() || out() || f.pending || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,159}$/.test(f.op ?? '')) return; + if (!c.canSend || !threadAccess() || c.archiveUnknown || 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(); diff --git a/packages/webui/src/public/requests.js b/packages/webui/src/public/requests.js index f9bea6858..ad3800384 100644 --- a/packages/webui/src/public/requests.js +++ b/packages/webui/src/public/requests.js @@ -99,7 +99,7 @@ let p = panels.get(id); if (!p) { const stored = load(id); - p = { id, state: null, missing: false, failed: false, stale: false, inbox: null, projects: null, material: null, materialError: '', writes: false, at: 0, observedAt: null, reading: null, stored, sig: '', + p = { id, state: null, missing: false, failed: false, stale: false, inbox: null, projects: null, material: null, materialError: '', writes: false, archiveUnknown: false, at: 0, observedAt: null, reading: null, stored, sig: '', ui: { pending: null, receipt: '', confirm: null, cancelAsk: false, revokeAsk: false, reason: '', draft: stored?.text ?? '' } }; panels.set(id, p); } @@ -180,7 +180,7 @@ function html(p) { const s = p.state, ui = p.ui; if (!s) return p.failed ? `

This request could not be read.

` : ''; - const busy = ui.pending || p.reading ? ' disabled' : '', can = p.writes && !p.stale; + const busy = ui.pending || p.reading ? ' disabled' : '', can = p.writes && !p.stale && !p.archiveUnknown, canAttach = p.writes && !p.stale; const q = s.question && text(s.question.id) && text(s.question.text) ? s.question : null; const returnHash = /^#\/chat\/threads\//.test(location.hash) || /^#\/projects\/threads\//.test(location.hash) ? location.hash : ''; let body = `${returnHash ? `Return to conversation` : ''}

Request Phase: ${txt(s.phase)}${s.mode ? ` Mode: ${txt(s.mode)}` : ''}

`; @@ -210,7 +210,7 @@ ? `

The project manager asks:

${txt(q.text)}

` : `

Answer received: ${txt(q.text)}. Application is not established by this receipt.

`; } - if (!can) body += p.stale ? '

Writes are unavailable until the request is read again.

' : '

Read-only: this Console cannot answer or change requests.

'; + if (!can) body += p.archiveUnknown ? '

Archive status unavailable. Some changes are unavailable.

' : p.stale ? '

Writes are unavailable until the request is read again.

' : '

Read-only: this Console cannot answer or change requests.

'; if (can && ui.confirm) { const c = ui.confirm; body += `

${txt(c.question)}

Your choice: ${txt(c.text)}

@@ -222,7 +222,7 @@ body += `

Each button opens a confirmation first. Nothing is answered until you confirm.

${p.inbox.console.options.map(o => ``).join(' ')}
`; } - if (can && pickerOpen(s)) { + if (canAttach && pickerOpen(s)) { const picked = s.wait.code === 'request-attachment-selected' ? s.wait.subject : null; body += Array.isArray(p.projects) ? `
${p.projects.length ? `

Projects you may use for this request. Picking one asks the project manager to propose it; you confirm it next.

${p.projects.map(x => @@ -277,7 +277,7 @@ (focus && !focus.disabled ? focus : el.querySelector('.request-receipt'))?.focus({ preventScroll: true }); } } - const signature = p => JSON.stringify([p.state, p.inbox, p.projects, p.material, p.materialError, p.failed, p.stale, p.missing, p.writes, Boolean(p.reading)]); + const signature = p => JSON.stringify([p.state, p.inbox, p.projects, p.material, p.materialError, p.failed, p.stale, p.missing, p.writes, p.archiveUnknown, Boolean(p.reading)]); // The reads that follow a state: the mode decision the selection waits on and the project picker. async function side(p) { @@ -357,7 +357,7 @@ async function answer(p, again = false) { const s = p.state, ui = p.ui; - if (!p.writes || ui.pending || !s || !openQuestion(s)) return; + if (!p.writes || p.archiveUnknown || ui.pending || !s || !openQuestion(s)) return; let rec; if (again) { rec = p.stored; @@ -386,21 +386,21 @@ } async function mode(p, next) { const s = p.state; - if (!p.writes || p.ui.pending || !s || !MODES.includes(next) || next === s.mode || !MODE_PHASES.includes(s.phase) || !positive(s.mode_revision)) return; + if (!p.writes || p.archiveUnknown || p.ui.pending || !s || !MODES.includes(next) || next === s.mode || !MODE_PHASES.includes(s.phase) || !positive(s.mode_revision)) return; p.ui.receipt = ''; settle(p, await send(p, 'mode', { revision: s.revision, mode_revision: s.mode_revision, mode: next, op: opId('mode') }), `Mode is now ${next}. A PRD approval waiting in the old mode is withdrawn and the request returns to discovery.`, 'Mode not changed'); } async function cancel(p) { const s = p.state; - if (!p.writes || p.ui.pending || !s || ENDED.includes(s.phase)) return; + if (!p.writes || p.archiveUnknown || p.ui.pending || !s || ENDED.includes(s.phase)) return; p.ui.receipt = ''; p.ui.cancelAsk = false; settle(p, await send(p, 'cancel', { revision: s.revision, op: opId('cancel') }), 'Request cancelled. Any project already created is kept.', 'Not cancelled'); } async function revoke(p) { const s = p.state, m = p.material, ui = p.ui; - if (!p.writes || p.stale || p.reading || ui.pending || !ui.revokeAsk || !s || !m?.snapshot.id || !isMaterial(m, s)) return; + if (!p.writes || p.archiveUnknown || p.stale || p.reading || ui.pending || !ui.revokeAsk || !s || !m?.snapshot.id || !isMaterial(m, s)) return; if (!ui.reason.trim() || ui.reason.length > 1024 || ui.reason.includes('\0')) { ui.receipt = 'Write a reason of 1–1024 characters before revoking.'; draw(p); return; } ui.receipt = ''; const r = await send(p, 'revoke', { project: s.project, snapshot: m.snapshot.id, revision: m.snapshot.revision, reason: ui.reason, op: opId('revoke') }); @@ -422,7 +422,7 @@ const notAnswered = r => `Not answered here: ${r.message} (${r.code}).${r.code === 'outcome-unknown' ? ' Nothing was sent again.' : ''}`; async function choose(p, key) { const row = p.inbox, ui = p.ui; - if (!p.writes || p.stale || p.reading || ui.pending || !row || !row.console.options.some(o => o.key === key)) return; + if (!p.writes || p.archiveUnknown || p.stale || p.reading || ui.pending || !row || !row.console.options.some(o => o.key === key)) return; const revision = p.state.revision, candidate = prdDecision(p.state) ? p.material?.candidate : null; if (prdDecision(p.state) && !candidate) return; ui.pending = 'choice'; ui.receipt = ''; ui.confirm = null; draw(p); @@ -440,7 +440,7 @@ } async function confirm(p) { const c = p.ui.confirm, ui = p.ui; - if (!p.writes || p.stale || p.reading || ui.pending || !c || c.revision !== p.state?.revision || + if (!p.writes || p.archiveUnknown || p.stale || p.reading || ui.pending || !c || c.revision !== p.state?.revision || c.candidate && (!same(c.candidate, p.material?.candidate) || p.state.wait?.subject !== c.id)) return; ui.pending = 'confirm'; ui.receipt = ''; draw(p); const { res, json } = await post(`/api/decisions/${enc(c.id)}/resolve`, { choice: c.choice, confirmation: c.confirmation }); @@ -509,7 +509,7 @@ // Called by chat.js after each paint with the chat container. A panel draws from what it already // knows at once, so a repaint never blanks it, and reads again only when its state is stale. - function mount(container) { + function mount(container, { archiveUnknown = false } = {}) { if (out() || !container) return; const mounts = [...container.querySelectorAll('.request-mount[data-request]')].filter(el => ID.test(el.dataset.request)); if (!mounts.length) return; @@ -519,6 +519,7 @@ if (!el.isConnected) continue; const p = panel(el.dataset.request); p.writes = cfg.writes; + p.archiveUnknown = archiveUnknown; if (p.state || p.failed) draw(p); if (!p.reading && Date.now() - p.at > STALE) void read(p); } diff --git a/packages/webui/src/request-http.mjs b/packages/webui/src/request-http.mjs index ff08a27e1..5b0f159ff 100644 --- a/packages/webui/src/request-http.mjs +++ b/packages/webui/src/request-http.mjs @@ -49,6 +49,7 @@ export const REQUEST_REFUSALS = Object.freeze({ 'request-mode-revision-conflict': [409, 'The mode changed. Read the request again.'], 'request-question-closed': [409, 'That question is already answered.'], 'request-question-mismatch': [409, 'That is not the question the project manager is waiting on.'], + 'project-archived': [409, 'This project is archived.'], 'request-project-refused': [403, 'That project cannot be picked for this request.'], 'request-existing-project-changed': [409, 'The project mapping changed. Read the project picker again.'], 'request-operation-conflict': [409, 'That operation id was already used for a different change.'], @@ -71,6 +72,7 @@ const BUS_CODES = { 'peercred-required': 'peercred-required', 'request-not-found': 'request-not-found', 'request-revision-conflict': 'request-revision-conflict', 'request-phase-refused': 'request-phase-refused', 'request-mode-revision-conflict': 'request-mode-revision-conflict', + 'project-archived': 'project-archived', 'request-question-closed': 'request-question-closed', 'request-question-mismatch': 'request-question-mismatch', 'request-project-refused': 'request-project-refused', 'request-existing-project-changed': 'request-existing-project-changed', diff --git a/packages/webui/tests/archive-unknown-browser.test.mjs b/packages/webui/tests/archive-unknown-browser.test.mjs index 6cf8e6927..d0686a38a 100644 --- a/packages/webui/tests/archive-unknown-browser.test.mjs +++ b/packages/webui/tests/archive-unknown-browser.test.mjs @@ -70,3 +70,76 @@ test('a failed archive lookup cannot hide the real broker refusal of an archived assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'must be refused'); assert.equal(store.get('SELECT count(*) n FROM messages').n, before); }); + +test('unknown status suppresses unguarded controls and retains guarded controls', async t => { + const { question, wait, draft, candidate, DECISION } = await import('./helpers/w15-requests.mjs'); + const active = {...draft, snapshot:{id:'snapshot-1',revision:2}}; + const current = state({ question, wait:wait('question','question','q-1'), candidate, snapshot:'snapshot-1' }); + const {b,navigate} = await setup(t,{states:{[REQUEST]:current}, draft:active, archiveFailure:'403'}); + await navigate(1000); + assert.match(await b.evaluate('document.querySelector(".chat-area").textContent'), /archive status unavailable.*may be refused/i); + for (const selector of ['#chat-session-form button', '#chat-share-form button', '[data-unshare]', '.request-answer', '[data-request-mode]', '[data-request-cancel]', '[data-request-revoke]', '[data-revert]']) { + assert.equal(await b.evaluate(`!document.querySelector(${JSON.stringify(selector)}) || document.querySelector(${JSON.stringify(selector)}).disabled`), true, selector); + } + assert.equal(await b.evaluate(`(()=>{const e=document.querySelector('#chat-operation');e.value='clear-1';e.dispatchEvent(new Event('input',{bubbles:true}));return document.querySelector('#chat-cancel').disabled})()`), true, 'cancel clear'); + for (const selector of ['#chat-send-form button', '#chat-resume', '#chat-clear']) assert.equal(await b.evaluate(`!document.querySelector(${JSON.stringify(selector)}).disabled`), true, selector); + assert.match(await b.evaluate('document.querySelector(".request-panel").textContent'), /Which platform/); + assert.deepEqual(await b.evaluate('window.__writes'), []); +}); + +test('unknown status suppresses decision writes but keeps the guarded picker', async t => { + const { wait, DECISION } = await import('./helpers/w15-requests.mjs'); + const choice = state({project:null,phase:'selection',selection:{decision:DECISION},wait:wait('decision','request-mode',DECISION)}); + const inbox = [{id:DECISION,question:'Choose',console:{kind:'decision',options:[{key:'existing',text:'Existing project'}]}}]; + const {b,navigate} = await setup(t,{states:{[REQUEST]:choice},inbox,projects:[{project:'old',name:'Old'}],archiveFailure:'403'}); + await navigate(1000); + assert.equal(await b.evaluate("!!document.querySelector('[data-request-choice]')"), false); + await b.evaluate(`window.__states['${REQUEST}']=${JSON.stringify(state({project:null,phase:'selection',route:'existing',wait:wait('setup','request-existing-project',REQUEST)}))};document.querySelector('[data-request-reload]').click();true`); + await b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(document.querySelector('[data-request-project]'))return true;await new Promise(r=>setTimeout(r,25))}throw Error('picker missing')})()`); + assert.equal(await b.evaluate("document.querySelector('[data-request-project]').disabled"),false); +}); + +test('a later successful lookup restores live controls or shows the archived state', async t => { + const { question, wait, draft, candidate, DECISION } = await import('./helpers/w15-requests.mjs'); + const current = state({question, candidate, snapshot:'snapshot-1', wait:wait('question','question','q-1')}); + const {b,navigate} = await setup(t,{states:{[REQUEST]:current},draft:{...draft,snapshot:{id:'snapshot-1',revision:2}},shares:[{to:'user:reader'}],archiveFailure:'403',inbox:[{id:DECISION,question:'Choose',console:{kind:'decision',options:[{key:'existing',text:'Existing project'}]}}]}); + await navigate(1000); + assert.equal(await b.evaluate("!!document.querySelector('.request-answer')"), false); + await b.evaluate(`window.__archiveFailure=null;window.__liveProjects=[{id:'${'20000000-0000-4000-8000-000000000002'}',name:'Live',repo:null}];window.dispatchEvent(new HashChangeEvent('hashchange'));true`); + await b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(document.querySelector('.request-answer') && document.querySelector('#chat-session-form') && document.querySelector('#chat-share-form'))return true;await new Promise(r=>setTimeout(r,25))}throw Error('live controls missing')})()`); + for (const selector of ['.request-answer', '[data-request-mode]', '[data-request-cancel]', '[data-request-revoke]', '[data-unshare]']) assert.equal(await b.evaluate(`!!document.querySelector(${JSON.stringify(selector)})`), true, selector); + assert.equal(await b.evaluate(`(()=>{const e=document.querySelector('#chat-operation');e.value='clear-1';e.dispatchEvent(new Event('input',{bubbles:true}));return document.querySelector('#chat-cancel').disabled})()`), false); + const selection = state({project:null,phase:'selection',selection:{decision:DECISION},wait:wait('decision','request-mode',DECISION)}); + await b.evaluate(`window.__states['${REQUEST}']=${JSON.stringify(selection)};document.querySelector('[data-request-reload]').click();true`); + await b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(document.querySelector('[data-request-choice]'))return true;await new Promise(r=>setTimeout(r,25))}throw Error('decision not restored')})()`); + await b.evaluate(`window.__liveProjects=[];window.__archiveRows=[{id:'${'20000000-0000-4000-8000-000000000002'}',name:'Archived',repo:null,archived:true}];window.dispatchEvent(new HashChangeEvent('hashchange'));true`); + await b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(!document.querySelector('#chat-send-form') && document.querySelector('.chat-conversation'))return true;await new Promise(r=>setTimeout(r,25))}throw Error('archived controls still present')})()`); + assert.match(await b.evaluate('document.querySelector(".chat-area").textContent'), /this project is archived/i); + assert.doesNotMatch(await b.evaluate('document.querySelector(".chat-area").textContent'), /archive status unavailable/i); +}); + +test('real authenticated attach on an archived origin shows a known refusal', async t => { + const { atPicker } = await import('./helpers/archive-origin.mjs'); + const x = await atPicker(t); + const project = x.store.get('SELECT project FROM threads WHERE id=?',x.state.thread).project; + await x.call('human','project.archive',{project}); + const auth = await createSsoSession(t,{host:'chat.example.com'}); + const bus = async (verb,args) => { try { return await x.call('console',verb,args); } catch(e) { throw new BusReadError(e.code); } }; + const server = await startServer({port:0,sso:auth.sso,send:async()=>({}),requestRead:bus,requestWrite:bus,log:()=>{}}); + t.after(()=>close(server)); + const session = await auth.signIn(server.address().port), headers = auth.writeHeaders(session); + let response; + const forwardSend = req => new Promise((resolve,reject)=>{ + const out=request({host:'127.0.0.1',port:server.address().port,path:new URL(req.url).pathname,method:'POST',headers},res=>{ + let data='';res.on('data',chunk=>data+=chunk);res.on('end',()=>{response={status:res.statusCode,body:JSON.parse(data)};resolve(response);}); + });out.on('error',reject);out.end(req.postData); + }); + const {b,navigate} = await setup(t,{request:x.source.request,thread:x.state.thread,project,states:{[x.source.request]:x.state},projects:[{project:'old',name:'Old'}],archiveFailure:'403',forwardSend:true},{forwardSend}); + await navigate(1000); + await b.evaluate(`(async()=>{for(let i=0;i<200;i++){const e=document.querySelector('[data-request-project=old]');if(e){e.click();return true}await new Promise(r=>setTimeout(r,25))}throw Error('picker missing')})()`); + await b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(document.querySelector('.request-receipt')?.textContent.match(/Not picked|Outcome unknown/))return true;await new Promise(r=>setTimeout(r,25))}throw Error('receipt missing')})()`); + assert.equal(response.status,409); + assert.equal(response.body.error,'project-archived'); + assert.match(await b.evaluate('document.querySelector(".request-receipt").textContent'), /Not picked.*project-archived/); + assert.equal(x.journal.readRequest('demo',x.source.request).revision,x.state.revision); +}); diff --git a/packages/webui/tests/helpers/archive-origin.mjs b/packages/webui/tests/helpers/archive-origin.mjs new file mode 100644 index 000000000..529d7d7c5 --- /dev/null +++ b/packages/webui/tests/helpers/archive-origin.mjs @@ -0,0 +1,80 @@ +// #797 / #1839 Task 3: the owner's Console controls over the request journal, and the PM's question. +// The webui side is packages/webui/tests/request-http.test.mjs; this file drives the broker verbs +// those routes call, with a real capability for every caller. +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createHash } from 'node:crypto'; +import { Store } from '../../../bus/src/store.mjs'; +import { Broker } from '../../../bus/src/broker.mjs'; +import { createRequestStore } from '../../../bus/src/request-store.mjs'; +import { createRequestLifecycle } from '../../../bus/src/request-lifecycle.mjs'; + +const hash = x => createHash('sha256').update(x).digest('hex'); +const intent = 'Build a single-file timer.\nKeep these exact bytes.\n'; +const SUBJECT = { provider: 'oidc', iss: 'https://id.test', sub: 'jason' }; +function setup(t, overrides = {}) { + const root = mkdtempSync(join(tmpdir(), 'request-console-')); + const store = new Store(root); + t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); + const business = { id: 'demo', human: 'jason', vars: { 'bus.tester': true }, arbiters: { technical: 'cto', delivery: 'pm' }, + roles: Object.fromEntries(['pm', 'cto', 'coder', 'otherpm', 'chat'].map(key => [key, { + definition: key === 'otherpm' ? 'pm' : key === 'chat' ? 'assistant' : key, authority: { withinRole: ['message.send'], crossRole: [] }, + }])), projects: { factory: { root: join(root, 'factory') }, old: { root: join(root, 'old'), repo: join(root, 'old-code') }, + library: { kind: 'library', root: join(root, 'library') }, unknown: { root: join(root, 'unknown') } } }; + const plans = (state, args, source, existing) => ['autonomous', 'guided'].map(mode => ({ + version: 1, business: state.business, request: state.request, project: args.project, name: args.name, chat_project: null, mode, + organization_revision: 'org-1', source: { project: args.source_project, commit: 'a'.repeat(40), remote: null, forks: [] }, + code_root: existing ? business.projects[args.project].root : join(root, 'products/demo', args.project, 'code'), + records_root: existing ? business.projects[args.project].repo : join(root, 'projects/demo', args.project), tracker: { root: 1, project: args.tracker_project }, + staff: { pm: state.pm, technical_reviewer: 'cto', coder: 'coder', reviewer: 'cto', chief: 'cto' }, + intent: { text: source.text, sha256: hash(source.text), source_message: source.source_message, generated: false }, + project_vars: {}, existing, + })); + const host = { + listProjects: () => [ + { business: 'demo', project: 'old', name: 'Old', tracker: { root: 1, project: 2 } }, + { business: 'demo', project: 'library', name: 'Library', tracker: { root: 1, project: 2 } }, + { business: 'foreign', project: 'old', name: 'Foreign', tracker: { root: 1, project: 2 } }, + { business: 'demo', project: 'unknown', name: 'Unknown', tracker: null }, + ], + preparePlans: (state, args, source) => plans(state, args, source, false), + prepareExisting: (state, args, source) => plans(state, args, source, true)[0], + admitPlan: () => ({ ready: true }), + }; + let lifecycle, journal; + const b = new Broker({ store, businesses: { demo: business }, requestLifecycle: services => { + journal = createRequestStore(store); lifecycle = createRequestLifecycle({ journal, services: { ...host, ...overrides, ...services } }); return lifecycle; + } }); + const caps = { human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }), + reader: b.bindReader({ business: 'demo' }), tester: b.bindTester({ business: 'demo' }), + console: b.bindConsole({ business: 'demo', human: 'jason', subject: SUBJECT }) }; + for (const role of ['pm', 'cto', 'coder', 'otherpm']) { + caps[role] = b.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi' }); + b.request(caps[role], { verb: 'role.claim' }); + } + const call = async (who, verb, args = {}) => b.request(caps[who], { verb, args }); + const originProject=b.request(caps.human,{verb:'project.create',args:{name:'Origin chat'}}); + const originThread=b.request(caps.human,{verb:'thread.create',args:{project:originProject.id,name:'pm',instance:'chat'}}); + const source=b.request(caps.human,{verb:'thread.send',args:{thread:originThread.id,body:intent}}); + caps.chat=b.bindLaunch({business:'demo',role:'chat',run:'chat-run',harness:'pi',thread:originThread.id}); + b.request(caps.chat,{verb:'role.claim'}); + b.request(caps.chat,{verb:'message.send',args:{to:'pm',body:intent,in_reply_to:source.id}}); + const propose = state => ({ request: source.request, revision: state.revision, op: 'plan-1', source_project: 'factory', project: 'timer', name: 'Timer', tracker_project: 2 }); + const existing = (state, op) => ({ request: state.request, revision: state.revision, op, source_project: 'old', project: 'old', name: 'Old', tracker_project: 2 }); + return { root, b, store, call, source, propose, existing, business, get journal() { return journal; }, get lifecycle() { return lifecycle; } }; +} +const n = (x, sql, ...p) => x.store.get(sql, ...p).n; +const get = (x, who = 'console') => x.call(who, 'request.get.console', { request: x.source.request }); +async function atSelection(t) { + const x = setup(t), opened = await x.call('pm', 'request.open', { request: x.source.request }); + x.state = await x.call('pm', 'request.propose', x.propose(opened)); + return x; +} +async function atPicker(t) { + const x = await atSelection(t); + await x.call('human', 'decision.resolve', { id: x.state.selection.decision, choice: 'existing' }); + x.state = await x.lifecycle.resume('demo', x.source.request); + return x; +} +export { atPicker }; diff --git a/packages/webui/tests/helpers/w15-requests.mjs b/packages/webui/tests/helpers/w15-requests.mjs index c03b511c6..f032f3201 100644 --- a/packages/webui/tests/helpers/w15-requests.mjs +++ b/packages/webui/tests/helpers/w15-requests.mjs @@ -15,21 +15,21 @@ export const wait = (kind, code, subject) => ({ kind, code, subject }); export const question = { id: 'q-1', text: 'Which platform?', answer: null, options: [] }; export async function setup(t, boot, { forwardSend = null } = {}) { - const thread = boot.thread || THREAD, project = boot.project || thread; + const thread = boot.thread || THREAD, project = boot.project || thread, requestId = boot.request || REQUEST; const f = await fixture(); t.after(() => f.close()); const b = await browser({ machine: false }); t.after(() => b.close()); const css = await readFile(new URL('../../src/public/requests.css', import.meta.url), 'utf8'); const source = `(() => { const RealDate = Date; window.Date = class extends RealDate { constructor(...args) { super(...(args.length ? args : ['2026-11-05T12:00:00Z'])); } }; const realFetch = window.fetch.bind(window); - const boot = ${JSON.stringify(boot)}; window.__states = boot.states; window.__failure = false; window.__writes = []; window.__unexpected = []; window.__events = []; window.__fileCalls = []; + const boot = ${JSON.stringify(boot)}; window.__archiveFailure = boot.archiveFailure; window.__archiveRows = boot.archiveRows || []; window.__liveProjects = []; window.__states = boot.states; window.__failure = false; window.__writes = []; window.__unexpected = []; window.__events = []; window.__fileCalls = []; document.addEventListener('mosaic-request-state', e => window.__events.push(e.detail)); document.addEventListener('DOMContentLoaded', () => { window.MosaicFiles = { mount({container, requestIds}) { window.__fileCalls.push(requestIds); container.textContent = 'Fixture Files mount'; } }; }); document.addEventListener('DOMContentLoaded', () => { const s=document.createElement('style'); s.textContent=${JSON.stringify(css)}; document.head.append(s); }); window.fetch = async (url, init = {}) => { const path = String(url), method = init.method || 'GET'; const json = (v, status=200) => new Response(JSON.stringify(v), {status, headers:{'content-type':'application/json'}}); - if (method === 'POST' && boot.forwardSend && path.endsWith('/messages')) return realFetch(url, init); + if (method === 'POST' && boot.forwardSend && (path.endsWith('/messages') || path.endsWith('/attach'))) return realFetch(url, init); if (method !== 'GET') { window.__writes.push({path, method}); if (path === '/api/decisions/${DECISION}/confirmation' && boot.allowConfirmation) return json({id:'${DECISION}',action:'answer',kind:'decision',choice:'yes',text:'Approve exact PRD',question:'Approve the exact PRD?',confirmation:'fixture-token'}); return json({error:'fixture-refusal'}, 403); } if (path === '/api/config') return json({send:true, requests:true, requestWrites:true}); if (path === '/api/processing') return json({version:1,generation:'12345678-1234-4234-8234-123456789abc',halted:false}); @@ -38,18 +38,19 @@ export async function setup(t, boot, { forwardSend = null } = {}) { if (path.startsWith('/api/bus/')) return json({rows:[], unread:0, at:'2026-11-05T12:00:00Z'}); if (path === '/api/business') return json({projects:[]}); if (path === '/api/chat/instances') return json({rows:[{instance:'assistant',name:'assistant'}]}); - if (path === '/api/chat/projects') return json({rows:[]}); + if (path === '/api/chat/projects') return json({rows:window.__liveProjects}); if (path === '/api/chat/projects/archived') { - if (boot.archiveFailure === 'network') throw new TypeError('fixture network failure'); - if (boot.archiveFailure === 'malformed') return json({rows: 'invalid'}); - if (boot.archiveFailure) return json({error:'unavailable'}, 403); - return json({rows:[]}); + if (window.__archiveFailure === 'network') throw new TypeError('fixture network failure'); + if (window.__archiveFailure === 'malformed') return json({rows: 'invalid'}); + if (window.__archiveFailure) return json({error:'unavailable'}, 403); + return json({rows:window.__archiveRows}); } - if (path.startsWith('/api/chat/threads') && path.endsWith('/sessions')) return json({rows:[{id:'${thread}',name:'default',default:true}]}); + if (path.startsWith('/api/chat/threads') && path.endsWith('/shares')) return json({rows:boot.shares || []}); + if (path.startsWith('/api/chat/threads') && path.endsWith('/sessions')) return json({rows:[{id:'${thread}',name:'default',default:true,state:'active',revision:1}]}); if (path.startsWith('/api/chat/threads') && !path.includes('/messages')) return json({rows:[{id:'${thread}',project:'${project}',name:'Project',instance:'assistant'}]}); - if (path.startsWith('/api/chat/threads') && path.includes('/messages')) return json({rows:[{id:'${MESSAGE}',body:'Build a countdown timer',from_role:'human',at:'2026-11-05T12:00:00Z',request:'${REQUEST}'}],after:'${MESSAGE}',hasMore:false}); + if (path.startsWith('/api/chat/threads') && path.includes('/messages')) return json({rows:[{id:'${MESSAGE}',body:'Build a countdown timer',from_role:'human',at:'2026-11-05T12:00:00Z',request:'${requestId}'}],after:'${MESSAGE}',hasMore:false}); const m=/^\\/api\\/requests\\/([^/]+)(?:\\/(projects|draft))?$/.exec(path); - if (m) { if (m[2] === 'projects') return json([]); if (m[2] === 'draft') return json(boot.draft || {candidate:null,prd:null,requirements:[],snapshot:{id:null,revision:0}}); if(window.__failure) return json({error:'unavailable'},503); return window.__states[m[1]] ? json(window.__states[m[1]]) : json({error:'not-found'},404); } + if (m) { if (m[2] === 'projects') return json(boot.projects || []); if (m[2] === 'draft') return json(boot.draft || {candidate:null,prd:null,requirements:[],snapshot:{id:null,revision:0}}); if(window.__failure) return json({error:'unavailable'},503); return window.__states[m[1]] ? json(window.__states[m[1]]) : json({error:'not-found'},404); } window.__unexpected.push(path); return json({error:'unexpected fixture API'}, 403); }; })();`; @@ -60,7 +61,7 @@ export async function setup(t, boot, { forwardSend = null } = {}) { b.on('Runtime.consoleAPICalled', e => { if (e.type === 'error') consoleErrors.push(e.args.map(a => a.value || a.description || '').join(' ')); }); b.on('Fetch.requestPaused', e => { const url = e.request.url; - if (forwardSend && e.request.method === 'POST' && url.endsWith('/messages')) { + if (forwardSend && e.request.method === 'POST' && (url.endsWith('/messages') || url.endsWith('/attach'))) { void forwardSend(e.request).then(result => b.call('Fetch.fulfillRequest', { requestId:e.requestId, responseCode:result.status, responseHeaders:[{name:'content-type',value:'application/json'}], body:Buffer.from(JSON.stringify(result.body)).toString('base64') })).catch(() => b.call('Fetch.failRequest', { requestId:e.requestId, errorReason:'Failed' })); return; } diff --git a/packages/webui/tests/request-http.test.mjs b/packages/webui/tests/request-http.test.mjs index b1ddc9d7d..fd2dd88f2 100644 --- a/packages/webui/tests/request-http.test.mjs +++ b/packages/webui/tests/request-http.test.mjs @@ -519,3 +519,11 @@ test('the signed-in owner draft and revoke listener use the snapshot revision an assert.deepEqual(writes[1].args, BODIES.revoke); assert.equal(writes[1].ctx.subject.sub, 'user-a'); }); + +test('an archived project attachment is a known 409 refusal', async () => { + const s = setup({ write: async () => { throw new BusReadError('project-archived'); } }); + const res = await s.post('attach'); + assert.equal(res.status, 409); + assert.equal(res.json().error, 'project-archived'); + assert.equal(s.writes.length, 1); +});