From 29a3cc63238307d8644447c67a0364a6b03dfa36 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 5 Nov 2026 20:17:23 -0600 Subject: [PATCH 01/32] Fix MVP intake relay receipts and role guidance --- BUILD-LOG.md | 8 ++++++++ docs/SESSIONS.md | 2 ++ packages/bus/src/broker.mjs | 2 +- packages/bus/tests/replies.test.mjs | 15 +++++++++++++++ packages/bus/tests/threads.test.mjs | 2 ++ packages/business/tests/assistant.test.mjs | 2 ++ roles/assistant.md | 5 +++-- roles/chief-of-staff.md | 8 +++++++- roles/pm.md | 12 +++++++++--- 9 files changed, 49 insertions(+), 7 deletions(-) diff --git a/BUILD-LOG.md b/BUILD-LOG.md index 0fc793057..b9d1e60b6 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -6193,3 +6193,11 @@ Before: clean isolated checkout at e9271841f3af23bf2a72aa17d9d67a1ec5abe017; no After: personally authored three-file skills/ms-ux-improvement coordinator package, complementary to existing frontend execution guidance; final candidate-02 content manifest b93d7836ed7c70db62386192d6e1db5c4f867f841aaa7645c3c861def5a9b140. Retained 19 bounded clean-context behavioral samples / 186 raw files: six unguided baselines, seven candidate-01 samples including ready-review control, six candidate-02 samples. Candidate-01's two unnecessary frozen-method recovery conditions and already-correct control remain recorded; narrowed wording and final tests show no extra gate, without causal/statistical improvement claim. Fresh package-focused skill-creator/resource validation passed for workspace, frozen snapshot and scratch copy; patch/whitespace, arithmetic, integrity, unchanged baseline and append-only ownership checks passed. Entry 637 words (2,210 total) and one-model synthetic/procedural-scope limits retained as author concerns. Exact source, patch, hashes, commands, raw evidence and independent-review brief: /mnt/storage/scratch/mosaic-lead/skill-ux-improvement/author/run-20261105T022125Z/HANDOFF.md. Author task complete; independent source approval pending. No application suites, commits, forge/queue writes, publication, installation, runtime activation or protected actions. 2026-11-04 | #818 / Gitea #1845 canonical skill integration verified | Exact3 reviewed Markdown files match source hashes; canonical skill-creator validator and whitespace checks pass. Independent review APPROVE/no findings,14 focused checks and a fresh bounded forward-use handoff retained. All prior canonical and author log bytes preserved. Package is available in source by explicit path; no managed/personal installation, activation, publication or user-acceptance claim. MVP activation remains pinned to parentbd8120318, independent of this skill commit. + +## 2026-11-05 — MVP 3 relay fixes (#797 / #1839), author phase + +Before: isolated checkout at 05fdfef9ea1fdddf58c725c04f229ca8559fc054, branch fix/mvp3-relay-797; live acceptance diagnosis identified configured-chief addressing, misleading relay receipts, bootstrap approval wording, and premature-staffing wording. Direct charter authorizes bounded implementation, tests and local commit only. Queue next refused linked worktree; no queue, forge, push or live action. F2 regression reproduced null receipt before repair; scratch evidence and final gate will be recorded in /mnt/storage/scratch/mosaic-lead/mvp3/relay-fix-r1-report.md. + +## 2026-11-05 — MVP 3 relay fixes (#797 / #1839), author phase completed + +After: assistant addresses the configured chief instance; role-send receipts derive provenance from the stored message and existing reply-chain checks; PM and chief guidance describes the existing bound bootstrap decision and request-based staffing before task creation. No role JSON, schema, policy or alias change. F2 whole-file regression was red (7 pass, 1 fail: null relay receipt) then green (8 pass), with unlinked, wrong-recipient and caller-supplied-ID negatives; threaded reply file passed 20/20. Final whole-suite gates: bus on Node 24.22.1 1596/1596; harness 242/242; business 307/307; 37 additional broker-importer files 512/512; zero failures, cancellations or skips. Original Node 26 bus attempt was interrupted after project-files stalled in a socket send: 1593 pass, one cancelled; that whole file passed 8/8 on Node 24.22.1 and the complete bus suite was rerun. Scratch TMPDIR, exact commands and logs: /mnt/storage/scratch/mosaic-lead/mvp3/relay-fix-r1-report.md. Local commit authorized; independent review and all publication or live actions remain outside this author phase. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index 3a717e1c2..fd498e0a1 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -2064,3 +2064,5 @@ are never rewritten or removed; corrections are new entries. 2026-11-05 | codex-ux-skill-author | Vikunja #818 UX improvement skill author handoff | Candidate-02 three-file package personally authored; package/copy/patch/arithmetic/integrity checks passed; 19 synthetic behavioral samples and all 186 raw artifacts retained with candidate-01 issue/correction and limits. Exact manifest b93d7836ed7c70db62386192d6e1db5c4f867f841aaa7645c3c861def5a9b140; evidence /mnt/storage/scratch/mosaic-lead/skill-ux-improvement/author/run-20261105T022125Z/HANDOFF.md. Independent source review pending; no commit, app suites, forge/queue writes, installation, activation or publication. 2026-11-04 | Mos, skill integrator | #818 / Gitea #1845 | Integrated exact independently approved ms-ux-improvement candidate02 into source;3hashes/validator/whitespace pass and author histories preserved. Separate commit from MVP; its activation target staysbd8120318. No install, activation, publication or live action. +2026-11-05 | Codex implementation sub-agent | #797 / #1839 MVP 3 relay fixes | Isolated author work on fix/mvp3-relay-797 at 05fdfef9ea1fdddf58c725c04f229ca8559fc054; queue refused linked worktree; local commit only, no live or push authority. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/relay-fix-r1-report.md. +2026-11-05 | Codex implementation sub-agent | #797 / #1839 MVP 3 relay fixes author handoff | Four bounded fixes; F2 red/green retained; final bus, harness, business and direct-importer gates 2657/2657 with zero failures/cancellations/skips. Local commit only; independent review remains. Full evidence: /mnt/storage/scratch/mosaic-lead/mvp3/relay-fix-r1-report.md. diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index a98048708..92879c6c3 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -6873,7 +6873,7 @@ export class Broker { const cancelled = this.#route(s.business, mid, to, thread); const request = s.human ? this.#event(s, 'human.input', { kind: 'instruction', message: mid }, mid) - : null; + : this.#request(s.business, this.#store.get('SELECT * FROM messages WHERE id=? AND business=?', mid, s.business)); // Row 271 (264d, B1): kept as evidence and cancelled at arrival, so it waits for nobody. if (cancelled) return { id: mid, request, cancelled }; const waiting = this.#waiting(s, mid, to, thread); diff --git a/packages/bus/tests/replies.test.mjs b/packages/bus/tests/replies.test.mjs index 600e1daf6..768aa20cb 100644 --- a/packages/bus/tests/replies.test.mjs +++ b/packages/bus/tests/replies.test.mjs @@ -49,6 +49,21 @@ const call = (b, cap, verb, args = {}) => b.request(cap, { verb, args }); const reply = (b, cap, to, body, extra = {}) => call(b, cap, 'message.send', { to: 'human', body, class: 'RESULT', in_reply_to: to, ...extra }); +test('send receipts derive request provenance only from a valid reply chain', (t) => { + const { b, human, coder, pm, sent } = setup(t); + assert.ok(sent.request, 'a human send creates the original request'); + const relay = call(b, coder, 'message.send', { to: 'pm', in_reply_to: sent.id, body: 'Please handle this' }); + assert.equal(relay.request, sent.request); + const onward = call(b, pm, 'message.send', { to: 'human', in_reply_to: relay.id, body: 'Handled' }); + assert.equal(onward.request, sent.request); + const unlinked = call(b, pm, 'message.send', { to: 'coder', body: `request ${sent.request}` }); + assert.equal(unlinked.request, null, 'body text does not confer provenance'); + const wrongRecipient = call(b, pm, 'message.send', { to: 'human', in_reply_to: sent.id, body: 'Not the recipient' }); + assert.equal(wrongRecipient.request, null, 'a sender cannot claim another role’s reply'); + assert.throws(() => call(b, coder, 'message.send', { to: 'pm', body: 'forged', request: sent.request }), { code: 'invalid-request' }); + assert.ok(call(b, human, 'message.send', { to: 'pm', body: 'Separate request' }).request); +}); + test('a reply is listed for human and reader sessions, and its delivery rows are unchanged', (t) => { const { b, store, human, reader, coder, pm, sent, deliveries, count } = setup(t); call(b, coder, 'message.receive'); diff --git a/packages/bus/tests/threads.test.mjs b/packages/bus/tests/threads.test.mjs index ae44fb66a..804a1a924 100644 --- a/packages/bus/tests/threads.test.mjs +++ b/packages/bus/tests/threads.test.mjs @@ -218,6 +218,8 @@ test('REQ-MSG-1: a message to or from the assistant names its thread, or is refu // Accepted messages: the user's, the assistant's to the user and the chief, a decision close. const asked = call('human', 'thread.send', { thread: A, body: 'look at the build' }); const toUser = call('a', 'message.send', { to: 'human', body: 'on it', in_reply_to: asked.id }); + assert.equal(toUser.request, asked.request); + assert.ok(toUser.request, 'threaded reply retains the original request'); const toChief = call('b', 'message.send', { to: 'chief', body: 'need a coder', class: 'REQUEST' }); assert.equal(threadOf(asked.id), A); assert.equal(threadOf(toUser.id), A); diff --git a/packages/business/tests/assistant.test.mjs b/packages/business/tests/assistant.test.mjs index 2636de39a..1030b8d1c 100644 --- a/packages/business/tests/assistant.test.mjs +++ b/packages/business/tests/assistant.test.mjs @@ -153,6 +153,8 @@ test("the contract passes work to the chief of staff and directs no one", () => assert.match(text, /don't direct\s+other agents/); assert.match(text, /create or assign\s+tasks/); assert.match(text, /launch\s+sessions/); + assert.match(text, /business's configured chief of staff instance, using\s+its instance key or name/); + assert.doesNotMatch(text, /addressed to the `chief-of-staff` role/); // Round 1 F1: rulings go to the chief of staff, never to the role they concern (REQ-ASSIST-1, REQ-ASSIST-2). assert.match(text, /Relay each ruling the user gives verbatim to the chief\s+of\s+staff,\s+naming\s+whose\s+ruling\s+it\s+is\s+and\s+which\s+role\s+it\s+concerns/); assert.match(text, /Don't\s+send\s+it\s+to\s+that\s+role\s+yourself/); diff --git a/roles/assistant.md b/roles/assistant.md index 07b2083b0..19a4fe575 100644 --- a/roles/assistant.md +++ b/roles/assistant.md @@ -18,8 +18,9 @@ sessions. - Talk with the user. Answer what they ask, say plainly what you don't know and keep their requests in their own words. - Send each request for the business to the chief of staff as a message, - addressed to the `chief-of-staff` role. Keep the user's words as they - gave them, and name the request each message carries. + addressed to the business's configured chief of staff instance, using + its instance key or name. Keep the user's words as they gave them, and + name the request each message carries. - Relay each ruling the user gives verbatim to the chief of staff, naming whose ruling it is and which role it concerns. Don't summarize it, paraphrase it or add to it. Don't send it to that role yourself. diff --git a/roles/chief-of-staff.md b/roles/chief-of-staff.md index 2deb05c51..bf4bb3ed6 100644 --- a/roles/chief-of-staff.md +++ b/roles/chief-of-staff.md @@ -15,6 +15,10 @@ product's code, you don't review it and you don't create tasks yourself. typed operation. Launch existing configured PM/CTO/coder/reviewer instances through `launch` under the current grants and limits. Do not hire them through your empty hireable list, relay a user command, or ask PM to launch. +- For new-project intake, the owner's mode/resource-plan decision already has + action `project.bootstrap`. Confirming a new-project choice authorizes its + bound plan; lifecycle settle checks the decision and bootstraps it without an + inherent second bootstrap approval. Report actual setup refusals and waits. - Preserve every exact launch refusal in your reply. The host records it for intake status and retries the same chief path after prerequisites change. When a role's frozen manifest lacks an admitted product, request its ordinary @@ -35,7 +39,9 @@ product's code, you don't review it and you don't create tasks yourself. the sender why not with a message `in_reply_to` the waiting message. Don't reply to `_bus`; it receives nothing. - Answer a staff request. The `pm` role sends you a STAFF-REQUEST message - naming a task and the roles it needs, each with a count. No other role + naming the original request or a task and the roles it needs, each with a + count. A request may precede mode selection, requirement approval and task + creation. No other role can send one. Launch what the request names with `launch`, within the same limits, one session per instance. Reply to the request message with the instances you launched, and for anything you didn't launch, diff --git a/roles/pm.md b/roles/pm.md index 896497a99..2970a5ec5 100644 --- a/roles/pm.md +++ b/roles/pm.md @@ -31,7 +31,11 @@ order. You don't write the product's code and you don't review it. The broker raises the new-project question with the exact resource plans and autonomous, guided or existing choices, or records a precise setup wait. The user chooses and confirms; a proposal grants no bootstrap authority. - Check status after the answer or a setup refusal before continuing. + The mode/resource-plan decision itself has action `project.bootstrap`; + confirming a new-project choice authorizes its bound plan. Lifecycle settle + checks that decision and bootstraps the plan, without an inherent second + bootstrap approval. A setup refusal can still cause a wait. Check status + after the answer or a setup refusal before continuing. - During discovery, PRD review or planning, use `intake_question` to ask the owner a bounded question, optionally with choices. Cite the current revision and a stable operation id. Check `intake_status` if the outcome is unknown; @@ -61,8 +65,10 @@ order. You don't write the product's code and you don't review it. - Take the work the chief of staff passes you. You don't launch sessions. When intake or a task needs another role session, ask the chief of staff with the `request_staff` tool: pass exactly one of the original `request` - or `task_ref`, and name each role with how - many sessions it needs. The chief of staff answers by message with the + or `task_ref`, and name each role with how many sessions it needs. A staff + request citing the original request can be made before mode selection or + requirement approval, before any task exists; tasks still wait for snapshot + activation. The chief of staff answers by message with the instances it launched, or with a refusal and its reason. The limits are the chief of staff's to apply, not yours. - When the bus tells you the human saved `spec/north-star.md`, read the From 62bece4a9da803d0734b435d157578ea5c8d2001 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 5 Nov 2026 20:28:16 -0600 Subject: [PATCH 02/32] fix(lifecycle): explain incomplete planning coverage (#797) --- BUILD-LOG.md | 8 +++ docs/SESSIONS.md | 2 + packages/bus/src/broker.mjs | 24 ++++++++- packages/bus/src/request-continuation.mjs | 2 +- .../bus/tests/request-continuation.test.mjs | 54 +++++++++++++++++++ packages/harness/src/tools.mjs | 2 +- packages/harness/tests/tools.test.mjs | 3 ++ roles/pm.md | 9 ++++ 8 files changed, 100 insertions(+), 4 deletions(-) diff --git a/BUILD-LOG.md b/BUILD-LOG.md index 0fc793057..6a74a7337 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -6193,3 +6193,11 @@ Before: clean isolated checkout at e9271841f3af23bf2a72aa17d9d67a1ec5abe017; no After: personally authored three-file skills/ms-ux-improvement coordinator package, complementary to existing frontend execution guidance; final candidate-02 content manifest b93d7836ed7c70db62386192d6e1db5c4f867f841aaa7645c3c861def5a9b140. Retained 19 bounded clean-context behavioral samples / 186 raw files: six unguided baselines, seven candidate-01 samples including ready-review control, six candidate-02 samples. Candidate-01's two unnecessary frozen-method recovery conditions and already-correct control remain recorded; narrowed wording and final tests show no extra gate, without causal/statistical improvement claim. Fresh package-focused skill-creator/resource validation passed for workspace, frozen snapshot and scratch copy; patch/whitespace, arithmetic, integrity, unchanged baseline and append-only ownership checks passed. Entry 637 words (2,210 total) and one-model synthetic/procedural-scope limits retained as author concerns. Exact source, patch, hashes, commands, raw evidence and independent-review brief: /mnt/storage/scratch/mosaic-lead/skill-ux-improvement/author/run-20261105T022125Z/HANDOFF.md. Author task complete; independent source approval pending. No application suites, commits, forge/queue writes, publication, installation, runtime activation or protected actions. 2026-11-04 | #818 / Gitea #1845 canonical skill integration verified | Exact3 reviewed Markdown files match source hashes; canonical skill-creator validator and whitespace checks pass. Independent review APPROVE/no findings,14 focused checks and a fresh bounded forward-use handoff retained. All prior canonical and author log bytes preserved. Package is available in source by explicit path; no managed/personal installation, activation, publication or user-acceptance claim. MVP activation remains pinned to parentbd8120318, independent of this skill commit. + +## 2026-11-05 — MVP 3 planning coverage implementation started (#797) + +Before: pinned 05fdfef9ea1fdddf58c725c04f229ca8559fc054; live b1869a13 has five approved requirements but one singly cited task. Scope: preserve scalar task schema, surface exact uncovered IDs to PM, clarify planning versus push evidence, diagnose coder stop and report D3–D5. Local branch only; no push, merge, runtime writes or role authority changes. Queue refused in linked worktree; direct implementation charter governs this child. + +## 2026-11-05 — MVP 3 planning coverage implementation ready for review (#797) + +After: preserve the scalar task requirement schema and fail-closed coverage gate; incomplete advance now sends a deduplicated PM bus notice naming every uncovered id and explaining correction, task-limit reconciliation and why a push cannot clear coverage. PM contract, task_create description and planning continuation state the same rule. Five-requirement regression proves prose/push do not cover missing ids, retries do not flood, changed coverage updates the notice, and complete assigned coverage advances without pushes for the other tasks. Focused whole files: 52 pass/0 fail/0 skip; scoped/lifecycle/import/role regressions: 115 pass/0 fail/0 skip; dependency/path/closure files: 61 pass/0 fail/0 skip (228 total). Initial fixture run: 51 pass/1 fail (new test used an invalid PRD author; corrected to pm.pm). Initial path checks: 28 pass/23 fail from absent worktree node_modules; canonical dependency versions matched package.json, linked locally, then all 61 passed. Read-only .53 evidence identifies proxy-stall watchdog as coder stop cause; D2–D5 remain report-only follow-ups. Detailed commands, receipts, limitations and exact commit: /mnt/storage/scratch/mosaic-lead/mvp3/planning-gate-fix-r1-report.md. No push, merge, runtime write, role authority expansion or product-acceptance claim. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index 3a717e1c2..d8b2018f2 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -2064,3 +2064,5 @@ are never rewritten or removed; corrections are new entries. 2026-11-05 | codex-ux-skill-author | Vikunja #818 UX improvement skill author handoff | Candidate-02 three-file package personally authored; package/copy/patch/arithmetic/integrity checks passed; 19 synthetic behavioral samples and all 186 raw artifacts retained with candidate-01 issue/correction and limits. Exact manifest b93d7836ed7c70db62386192d6e1db5c4f867f841aaa7645c3c861def5a9b140; evidence /mnt/storage/scratch/mosaic-lead/skill-ux-improvement/author/run-20261105T022125Z/HANDOFF.md. Independent source review pending; no commit, app suites, forge/queue writes, installation, activation or publication. 2026-11-04 | Mos, skill integrator | #818 / Gitea #1845 | Integrated exact independently approved ms-ux-improvement candidate02 into source;3hashes/validator/whitespace pass and author histories preserved. Separate commit from MVP; its activation target staysbd8120318. No install, activation, publication or live action. + +2026-11-05 | implementation child | #797 MVP 3 planning coverage at 05fdfef9e, branch fix/mvp3-planning-797 | scalar coverage guidance and actionable PM notice ready for review; 228 tests pass, no skips; coder proxy-stall diagnosed read-only, D3–D5 reported; queue refused linked worktree; local commit only, no push/merge/runtime writes. diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index a98048708..743bf7ef2 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -740,7 +740,14 @@ export class Broker { const created = events.filter(e => e.kind === 'task.created' && e.actor_role === state.pm && e.body.request === state.request && e.body.project_key === state.project && e.body.requirement_snapshot === state.snapshot && events.some(p => p.id === e.body.create_receipt && p.body.operation === 'tracker.create.prepared')); - if (!created.length || state.candidate.requirements.some(r => !created.some(e => e.body.requirement === r.id))) fail('request-evidence-required'); + const uncovered = state.candidate.requirements.filter(r => !created.some(e => e.body.requirement === r.id)).map(r => r.id); + if (uncovered.length) { + const error = new BusError('request-evidence-required', + `Uncovered requirement ids: ${uncovered.join(', ')}. Each task_create cites exactly one requirement id from this product snapshot; prose does not count. Check existing create receipts, then create and assign tasks for the uncovered ids within the approved task limit. If coverage would exceed that limit, ask the owner to reconcile the plan before creating more tasks. A push or review cannot clear planning coverage; retry advance_request after coverage and assignment are complete.`); + error.planningCoverage = true; + throw error; + } + if (!created.length) fail('request-evidence-required'); const tasks = [...new Set(created.map(e => e.subject))].sort(), evidence = created.map(e => e.id); if (state.phase !== 'planning' && digestPrd(tasks) !== digestPrd([...state.tasks].sort())) fail('request-evidence-required'); let changes = false; @@ -5448,7 +5455,20 @@ export class Broker { const method = REQUEST_VERBS[request.verb]; return Promise.resolve().then(() => this.#requests[method](s, structuredClone(args))) .then(result => { this.#secretCheck(result); if (request.verb === 'prd.submit') this.#scheduleRequests(); return result; }) - .catch(e => { throw this.#refused(s, e instanceof BusError ? e : new BusError(e.code ?? 'request-failed'), request); }); + .catch(e => { + // advance rolls its transaction back on refusal. Publish the actionable PM + // message afterwards, once per revision and coverage set, through the normal + // bus route: the sandbox tool socket only exposes the refusal code. + if (request.verb === 'request.advance' && e instanceof BusError && e.planningCoverage) { + const state = this.#requestJournal.readRequest(s.business, args.request); + const body = `Planning coverage for request ${state.request}, revision ${state.revision}:\n${e.reason}`; + this.#store.transaction(() => { + if (!this.#store.get('SELECT 1 FROM messages WHERE business=? AND from_role=? AND to_role=? AND body=?', s.business, NOTICE_ROLE, s.role, body)) + this.#continuationMessage(state, s.role, body, this.#continuationContext(state, s.role)); + }); + } + throw this.#refused(s, e instanceof BusError ? e : new BusError(e.code ?? 'request-failed'), request); + }); } // Row 213 (C2-08, Case 2 design 5.1 M6): a creative role has no free-text decision channel. It // runs before the spec-cited branch below, so a cited raise refuses too. diff --git a/packages/bus/src/request-continuation.mjs b/packages/bus/src/request-continuation.mjs index 3b2a0c4a4..f0059b8bc 100644 --- a/packages/bus/src/request-continuation.mjs +++ b/packages/bus/src/request-continuation.mjs @@ -56,7 +56,7 @@ export function createContinuations({ journal, services }) { ({ intake_propose: 'Read intake_status and intake_projects, preserve the original request intent, and propose the exact resource plan with intake_propose. If the owner selected existing, select an accessible existing mapping. The owner confirms the plan; discovery needs no task or tracker effect.', submit_prd: 'Read the committed spec and discovery records. Finish the product PRD and requirements with write_record, then submit_prd with their exact records commit, path, version and ids. Use request_staff for missing technical help.', resolve_decision: 'Read the exact PRD candidate and requirements with intake_status(path), one selected path at a time. Resolve only the cited PRD decision through resolve_decision; ask for changes if evidence is incomplete.', - task_create: 'Read list_requirements for this project and snapshot. Check existing task/create receipts before creating or retrying. Create and assign the scoped tasks, request_staff for coder/reviewer sessions, then advance_request.', + task_create: 'Read list_requirements for this project and snapshot. Check existing task/create receipts before creating or retrying. Each task_create cites exactly one requirement id; every approved requirement needs a cited task, even when one description claims several. Create and assign the scoped tasks within the approved task limit; if coverage would exceed it, ask the owner to reconcile the plan. Request_staff for coder/reviewer sessions, then advance_request. Planning checks coverage and assignment; a push or review cannot clear missing coverage. Read the broker message naming uncovered ids after a refusal and correct the plan before retrying.', push_working: 'Check the assigned tasks and existing receipts. Implement the approved requirements, push_working, and request_review for each latest commit.', review_verdict: 'Fetch each current review request with fetch_review, run acceptance checks, before the verdict, write a docs/*.md verification document whose entire content is raw JSON (no Markdown fences or prose): {"version":1,"task_ref":"...","review_request":"...","commit":"...","results":"..."}. Results must be a nonempty string. Return review_verdict with a raw JSON body containing exactly {"verification_record":{"path":"...","commit":"..."},"results":"..."}, using the write_record receipt and matching results.', advance_request: 'Check the existing task, push and review evidence, then call advance_request at the current intake_status revision. Never claim a success phase.', diff --git a/packages/bus/tests/request-continuation.test.mjs b/packages/bus/tests/request-continuation.test.mjs index 63563bdf7..80a767dea 100644 --- a/packages/bus/tests/request-continuation.test.mjs +++ b/packages/bus/tests/request-continuation.test.mjs @@ -55,6 +55,55 @@ test('wrong-role and claimed state advances fail; planning needs actual task evi await assert.rejects(f.call('pm', 'request.advance', { ...args, phase: 'complete' }), /invalid-request/); await assert.rejects(advance(f), /request-evidence-required/); assert.equal(f.journal.readRequest('demo', f.request).phase, 'planning'); + const notice = f.call('pm', 'message.receive').find(m => m.body.startsWith('Planning coverage for request ')); + assert.match(notice.body, /Uncovered requirement ids: REQ-POMO-1\./); + assert.match(notice.body, /A push or review cannot clear planning coverage/); +}); + +test('five requirements need five cited tasks; prose and push do not cover missing ids; PM can correct the refusal', async t => { + const f = await requestFixture(t); + for (let i = 2; i <= 5; i++) { + const id = `REQ-POMO-${i}`; + f.args.records_commit = f.save(`requirements/${id}.json`, JSON.stringify({ recordVersion: 1, id, title: `Timer ${i}`, text: `Acceptance ${i}`, status: 'draft' }), 'pm.pm'); + f.args.requirement_ids.push(id); + } + const sub = await f.submit(); f.approve(sub); await f.activate(sub); + const state = f.journal.readRequest('demo', f.request); + const { createDigest, createMarker } = await import('../src/broker.mjs'); + const create = i => { + const task_ref = `vikunja:2/${40 + i}`; + const prepared = { op: `timer-task-${i}`, request: f.request, requirement: `REQ-POMO-${i}`, project_key: 'timer', requirement_snapshot: state.snapshot, + project: 2, creator: 3, decision: null, view: { ids: { todo: 1, review: 2, done: 3 } }, labels: [], relations: [] }; + prepared.body = { title: 'Implements REQ-POMO-1..5', description: `All five requirements.\n${createMarker(prepared)}` }; + prepared.body_digest = createDigest(prepared.body); + const { receipt } = f.bus.broker.prepareTaskCreate(f.caps.pm, prepared); + let fields = { ...prepared.body, project_id: 2, bucket: 1, done: false, assignees: [], due_date: null, priority: 0 }; + const snapshot = () => ({ task_ref, fields, digest: createDigest(fields), updated: new Date().toISOString() }); + f.bus.broker.recordRecoveredCreate({ receipt, task_ref, snapshot: { ...snapshot(), creator: 3 } }); + fields = { ...fields, assignees: [5] }; + f.bus.broker.recordTask({ cap: f.caps.pm, business: 'demo', snapshots: [snapshot()], events: [{ kind: 'task.assigned', subject: task_ref, body: { role: 'coder', previous: [] } }] }); + return task_ref; + }; + const tasks = [create(1)]; + f.bus.broker.recordPush(f.caps.coder, { task_ref: tasks[0], project: 'timer', branch: 'timer', ref: 'work/vikunja-2-41', commit: 'b'.repeat(40), previous: null }); + await assert.rejects(advance(f, 'partial'), { code: 'request-evidence-required' }); + const notices = () => f.store.all("SELECT * FROM messages WHERE from_role='_bus' AND to_role='pm' AND body LIKE 'Planning coverage for request %'"); + assert.equal(notices().length, 1); + assert.match(notices()[0].body, /Uncovered requirement ids: REQ-POMO-2, REQ-POMO-3, REQ-POMO-4, REQ-POMO-5\./); + assert.match(notices()[0].body, /ask the owner to reconcile the plan/); + assert.match(notices()[0].body, /A push or review cannot clear planning coverage/); + assert.ok(f.call('pm', 'message.receive').some(m => m.id === notices()[0].id), 'notice reaches PM through the bus'); + assert.deepEqual(f.journal.readRequest('demo', f.request), state, 'refusal consumes no lifecycle revision or task evidence'); + await assert.rejects(advance(f, 'partial'), { code: 'request-evidence-required' }); + assert.equal(notices().length, 1, 'retries do not flood the same coverage notice'); + tasks.push(create(2)); + await assert.rejects(advance(f, 'partial'), { code: 'request-evidence-required' }); + assert.equal(notices().length, 2, 'changed coverage produces an updated notice'); + assert.match(notices()[1].body, /Uncovered requirement ids: REQ-POMO-3, REQ-POMO-4, REQ-POMO-5\./); + for (let i = 3; i <= 5; i++) tasks.push(create(i)); + const next = await advance(f, 'partial'); + assert.equal(next.phase, 'implementation'); + assert.deepEqual(next.tasks, tasks, 'complete coverage and assignment advance without pushes for the other tasks'); }); test('validated PRD transition acknowledges discovery and schedules planning without flooding', async t => { @@ -64,6 +113,11 @@ test('validated PRD transition acknowledges discovery and schedules planning wit await f.bus.requestLifecycle.recover(); assert.ok(f.journal.effects('demo', f.request).some(e => e.op === discovery.op && e.stage === 'committed' && e.result.evidence)); assert.ok(intents(f).some(e => e.result.intent.step === 'task_create')); + const planning = intents(f).find(e => e.result.intent.step === 'task_create'); + const body = messages(f, planning.result.intent.key)[0].body; + assert.match(body, /Each task_create cites exactly one requirement id/); + assert.match(body, /a push or review cannot clear missing coverage/); + assert.match(body, /approved task limit/); }); test('exact launch refusal is durable and returned by intake status, with bounded retries', async t => { diff --git a/packages/harness/src/tools.mjs b/packages/harness/src/tools.mjs index 9f32a8024..4a1b500c5 100644 --- a/packages/harness/src/tools.mjs +++ b/packages/harness/src/tools.mjs @@ -46,7 +46,7 @@ const PRIORITY = { type: "integer", minimum: 0, maximum: 5, description: "Priori // action -> [tool name, description, properties, required] const TASK_TOOLS = { - "task.create": ["task_create", "Create a tracker task. Every task cites the request it came from and a requirement id.", { + "task.create": ["task_create", "Create a tracker task. Every task cites its request and exactly one requirement id. Planning needs a cited task for every approved requirement; descriptions claiming several do not count. Stay within the approved task limit; ask the owner to reconcile a coverage/limit conflict. A push cannot clear missing planning coverage; read the broker message naming uncovered ids after advance_request refuses.", { title: str("Task title, one line"), project: { type: "integer", minimum: 1, description: "Optional tracker board assertion; must match the confirmed request binding" }, description: str("Task description"), diff --git a/packages/harness/tests/tools.test.mjs b/packages/harness/tests/tools.test.mjs index 9d51def27..4055ddf50 100644 --- a/packages/harness/tests/tools.test.mjs +++ b/packages/harness/tests/tools.test.mjs @@ -26,6 +26,9 @@ test("the PM gets its task verbs and the reads, and no launch", (t) => { const create = tools.find((x) => x.name === "task_create"); assert.deepEqual({ ...create.parameters.properties.project, description: undefined }, { type: "integer", minimum: 1, description: undefined }); assert.deepEqual(create.parameters.required, ["title", "request", "requirement"]); + assert.match(create.description, /exactly one requirement id/); + assert.match(create.description, /A push cannot clear missing planning coverage/); + assert.match(create.description, /approved task limit/); assert.deepEqual(tools.filter((x) => x.parameters.properties.project).map((x) => x.name), ["intake_propose", "task_create", "read_spec", "write_record", "raise_decision"]); for (const tool of tools) { assert.equal(tool.parameters.type, "object"); diff --git a/roles/pm.md b/roles/pm.md index 896497a99..56e6154da 100644 --- a/roles/pm.md +++ b/roles/pm.md @@ -18,6 +18,15 @@ order. You don't write the product's code and you don't review it. and reviewer staff from the chief, and call `advance_request` with the current revision and a stable operation id. It checks real task, push, review and closure evidence for the next fixed transition; it accepts no claimed phase. + Planning requires a cited task for every approved requirement and coder + assignment for each task. Each task cites exactly one requirement id; + descriptions claiming several requirements do not count as coverage. + On incomplete coverage, read the broker message naming the uncovered ids, + check existing create receipts, and create and assign the missing tasks + within the approved task limit. If coverage would exceed that limit, ask + the owner to reconcile the plan before creating more tasks. A push or review + cannot clear missing planning coverage. Retry `advance_request` after + coverage and assignment are complete; push evidence is checked afterwards. Finish the artifacts and close each task against its latest approve verdict, then advance to delivery through the chief and assistant. From 2f1cef4c1d307642ab2b7af58711730414a4e1ef Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 5 Nov 2026 20:39:48 -0600 Subject: [PATCH 03/32] fix(harness): expose trusted assistant coordinator context (#797) --- BUILD-LOG.md | 6 ++ docs/SESSIONS.md | 2 + packages/business/README.md | 7 ++ packages/business/src/resolve.mjs | 7 ++ packages/business/tests/assistant.test.mjs | 3 +- packages/harness/src/bundle.mjs | 3 + packages/harness/tests/bundle.test.mjs | 76 ++++++++++++++++++++++ roles/assistant.md | 8 ++- 8 files changed, 108 insertions(+), 4 deletions(-) diff --git a/BUILD-LOG.md b/BUILD-LOG.md index b9d1e60b6..d4308a204 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -6201,3 +6201,9 @@ Before: isolated checkout at 05fdfef9ea1fdddf58c725c04f229ca8559fc054, branch fi ## 2026-11-05 — MVP 3 relay fixes (#797 / #1839), author phase completed After: assistant addresses the configured chief instance; role-send receipts derive provenance from the stored message and existing reply-chain checks; PM and chief guidance describes the existing bound bootstrap decision and request-based staffing before task creation. No role JSON, schema, policy or alias change. F2 whole-file regression was red (7 pass, 1 fail: null relay receipt) then green (8 pass), with unlinked, wrong-recipient and caller-supplied-ID negatives; threaded reply file passed 20/20. Final whole-suite gates: bus on Node 24.22.1 1596/1596; harness 242/242; business 307/307; 37 additional broker-importer files 512/512; zero failures, cancellations or skips. Original Node 26 bus attempt was interrupted after project-files stalled in a socket send: 1593 pass, one cancelled; that whole file passed 8/8 on Node 24.22.1 and the complete bus suite was rerun. Scratch TMPDIR, exact commands and logs: /mnt/storage/scratch/mosaic-lead/mvp3/relay-fix-r1-report.md. Local commit authorized; independent review and all publication or live actions remain outside this author phase. + +## 2026-11-05 — MVP 3 relay R2 trusted coordinator context (#797 / #1839) + +Before: isolated branch fix/mvp3-relay-797-r2 from 29a3cc63238307d8644447c67a0364a6b03dfa36. Independent R1 review's sole CHANGES finding: owner-started assistants could not discover an arbitrary configured chief key, especially offline. Direct R2 charter authorizes trusted generated routing context, offline swapped-key integration evidence, required tests, append-only records and a local commit; no push or live action. Queue read refused linked worktree, then canonical read returned nothing; no queue mutation. + +After: assistant-only resolved coordinator context comes from validated launch.by and the coordinator's optional configured identity.name. The generated prompt names the exact instance key and role guidance uses it explicitly; no-launch context reports a blocker. Broker source, messaging policy, recipient checks and role JSON are unchanged. Config-to-bundle-to-broker integration covers owner-launched assistant, offline chief, violet/opal definition swap, descriptive name, ignored session override, correct stored recipient/provenance, forbidden CIO and definition-alias refusals. Required whole-file gate 321/321 and additional content/path/bundle checks 124/124 on Node 24.22.1; final touched bundle file 24/24, all zero failures/cancellations/skips. Existing digest machinery covers assistant coordinator and prompt bytes; no literal changed-source hash pin required an update. Original reviewer probes 12/13 because their F1 deepEqual asserts the diagnosed defect; preserved original and a scratch copy changing only that assertion to notDeepEqual passed 13/13, including F1. Initial added fixture runs had two setup mistakes (missing store directory, thread claims absent from agents); corrected. Initial related run 79/124 had missing local dependencies and one Claude style-hook assertion; npm ci from the unchanged lockfile followed by the same whole-group rerun passed 124/124. Scratch evidence: /mnt/storage/scratch/tmp/0T6C89; exact commands and limits: /mnt/storage/scratch/mosaic-lead/mvp3/relay-fix-r2-report.md. Local commit only; independent exact-candidate review remains. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index fd498e0a1..3dde53925 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -2066,3 +2066,5 @@ are never rewritten or removed; corrections are new entries. 2026-11-04 | Mos, skill integrator | #818 / Gitea #1845 | Integrated exact independently approved ms-ux-improvement candidate02 into source;3hashes/validator/whitespace pass and author histories preserved. Separate commit from MVP; its activation target staysbd8120318. No install, activation, publication or live action. 2026-11-05 | Codex implementation sub-agent | #797 / #1839 MVP 3 relay fixes | Isolated author work on fix/mvp3-relay-797 at 05fdfef9ea1fdddf58c725c04f229ca8559fc054; queue refused linked worktree; local commit only, no live or push authority. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/relay-fix-r1-report.md. 2026-11-05 | Codex implementation sub-agent | #797 / #1839 MVP 3 relay fixes author handoff | Four bounded fixes; F2 red/green retained; final bus, harness, business and direct-importer gates 2657/2657 with zero failures/cancellations/skips. Local commit only; independent review remains. Full evidence: /mnt/storage/scratch/mosaic-lead/mvp3/relay-fix-r1-report.md. +2026-11-05 | Codex implementation sub-agent | #797 / #1839 relay R2 trusted coordinator context | Chartered local branch fix/mvp3-relay-797-r2 from 29a3cc63238307d8644447c67a0364a6b03dfa36; isolated config-to-bundle-to-broker correction, no push or live authority. Queue refused linked worktree; canonical read returned nothing. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/relay-fix-r2-report.md. +2026-11-05 | Codex implementation sub-agent | #797 / #1839 relay R2 author handoff | Trusted configured-coordinator discovery with offline arbitrary-key integration; required 321/321, related 124/124, final touched bundle 24/24, corrected reviewer acceptance probes 13/13. Original probe preserved with obsolete equality assertion documented. Local commit only; independent review remains. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/relay-fix-r2-report.md. diff --git a/packages/business/README.md b/packages/business/README.md index a131e2686..033579253 100644 --- a/packages/business/README.md +++ b/packages/business/README.md @@ -335,6 +335,13 @@ outside the vocabulary. `launch` is set exactly when `classify(record, `role.launch` for the launcher, `launch` is null. A launcher should still ask `classify`, not read `launch` alone. +An assistant also receives `coordinator`: the validated business's `launch.by` +as `instance`, and that instance's configured `identity.name` as `name` (or +null). With no launch block, `coordinator` is null. This routing context is +included in the resolved digest and the generated session prompt; it does not +grant authority. The name is descriptive, not a broker display-name alias; +the assistant addresses messages to the exact instance key. + Errors are `BusinessError` with `exitCode` 2 (invalid) or 4 (a required file missing). The CLI adds 3 for a system config problem and 4 for usage. diff --git a/packages/business/src/resolve.mjs b/packages/business/src/resolve.mjs index 839662bd0..09c04ae43 100644 --- a/packages/business/src/resolve.mjs +++ b/packages/business/src/resolve.mjs @@ -92,6 +92,13 @@ export function resolveInstance({ system, business, project = null, instance }) credentials: Object.fromEntries(Object.entries(entry.credentials).filter(([kind]) => !isHostKind(kind))), tracker: entry.tracker, launch, + // #797 relay R2: an owner-started assistant cannot infer its coordinator + // from launchedBy or running claims. Only the validated business chooses + // this address; it grants no launch authority or messaging exception. + ...(definition.name === "assistant" ? { coordinator: business.launch ? { + instance: business.launch.by, + name: business.roles[business.launch.by].identity?.name ?? null, + } : null } : {}), // REQ-HARN-2: the search-source plug-in the organization file switches on, by name. webSource: business.plugins?.["search-source"] ?? null, // The role file's skill names. The bundle resolves each against /skills-enabled. diff --git a/packages/business/tests/assistant.test.mjs b/packages/business/tests/assistant.test.mjs index 1030b8d1c..821e86536 100644 --- a/packages/business/tests/assistant.test.mjs +++ b/packages/business/tests/assistant.test.mjs @@ -153,7 +153,8 @@ test("the contract passes work to the chief of staff and directs no one", () => assert.match(text, /don't direct\s+other agents/); assert.match(text, /create or assign\s+tasks/); assert.match(text, /launch\s+sessions/); - assert.match(text, /business's configured chief of staff instance, using\s+its instance key or name/); + assert.match(text, /exact instance key in the generated `Configured coordinator`\s+line under `This session`/); + assert.match(text, /Use that key as send_message\.to for requests\s+and rulings/); assert.doesNotMatch(text, /addressed to the `chief-of-staff` role/); // Round 1 F1: rulings go to the chief of staff, never to the role they concern (REQ-ASSIST-1, REQ-ASSIST-2). assert.match(text, /Relay each ruling the user gives verbatim to the chief\s+of\s+staff,\s+naming\s+whose\s+ruling\s+it\s+is\s+and\s+which\s+role\s+it\s+concerns/); diff --git a/packages/harness/src/bundle.mjs b/packages/harness/src/bundle.mjs index f4c65b148..fbeeb2c5a 100644 --- a/packages/harness/src/bundle.mjs +++ b/packages/harness/src/bundle.mjs @@ -268,6 +268,9 @@ function prompt({ contract, resolved, session, tools, untyped, builtins, persona "", `- Business: ${resolved.business}. Role instance: ${resolved.instance} (definition ${resolved.definition}). Run: ${session.run}.`, `- Launched by: ${session.launchedBy}. Harness: ${session.harness}, model ${session.model}.`, + ...(resolved.definition === "assistant" ? [resolved.coordinator + ? `- Configured coordinator: instance key ${JSON.stringify(resolved.coordinator.instance)}${resolved.coordinator.name ? ` (configured identity name: ${JSON.stringify(resolved.coordinator.name)})` : ""}. Use this exact instance key as send_message.to for business requests and rulings; the broker checks every send.` + : "- Configured coordinator: none. Report the missing business launcher configuration to the user; do not guess a recipient."] : []), `- Workspace: ${session.workspace}. File tools work inside it only.`, ...checkoutLines(session.checkouts ?? []), // Row 89 (#1583): the names the harness gives its built-ins, as the manifest lists them. diff --git a/packages/harness/tests/bundle.test.mjs b/packages/harness/tests/bundle.test.mjs index f2e26e8c4..fc8bb65f3 100644 --- a/packages/harness/tests/bundle.test.mjs +++ b/packages/harness/tests/bundle.test.mjs @@ -6,12 +6,88 @@ import { join } from "node:path"; import { BundleError, buildBundle, sessionModel, SOURCES } from "../src/bundle.mjs"; import { HARNESS, REPO_SKILLS, resolvedFor, scratch } from "./helpers.mjs"; import { decide } from "../src/gate.mjs"; +import { resolveInstance, validateBusinessDocument } from "../../business/src/index.mjs"; +import { businessDoc, REPO_ROLES, systemFor } from "../../business/tests/helpers.mjs"; +import { Store } from "../../bus/src/store.mjs"; +import { Broker } from "../../bus/src/broker.mjs"; const UNSLOP_DIR = new URL("../../../tools/unslop-hook/", import.meta.url).pathname; const sha = (p) => createHash("sha256").update(readFileSync(p)).digest("hex"); const mode = (p) => statSync(p).mode & 0o777; +// #797 relay R2: no running chief or launchedBy hint is needed to discover +// an arbitrary coordinator key. Exercise config -> resolve -> bundle -> bus. +test("an owner-started assistant identifies and can address the configured offline coordinator", (t) => { + const root = scratch(t); + const doc = businessDoc(root); + delete doc.roles.chief; + doc.roles.assistant = { definition: "assistant", vars: { harness: "pi", model: "glm-5.3-flash", thinking: "high" } }; + doc.roles.violet = { definition: "chief-of-staff", identity: { name: "Violet Dispatch", email: "violet@example.test", gitea: { url: "https://git.example", user: "violet" } } }; + doc.roles.opal = { definition: "chief-information-officer" }; + const records = [], prompts = []; + for (const coordinator of ["violet", "opal"]) { + const other = coordinator === "violet" ? "opal" : "violet"; + doc.roles[coordinator].definition = "chief-of-staff"; + doc.roles[other].definition = "chief-information-officer"; + doc.launch.by = coordinator; + const business = validateBusinessDocument(doc, join(root, "businesses", "acme.json"), { rolesDir: REPO_ROLES }); + const resolve = (instance) => resolveInstance({ system: systemFor(root), business, instance }); + const resolved = resolve("assistant"); + const expectedName = coordinator === "violet" ? "Violet Dispatch" : null; + assert.deepEqual(resolved.coordinator, { instance: coordinator, name: expectedName }); + assert.ok(Object.isFrozen(resolved.coordinator)); + assert.equal(resolved.launch, null, "routing context grants no launch authority"); + records.push(resolved); + + const workspace = join(root, coordinator); mkdirSync(workspace); + const bundle = buildBundle({ dir: join(workspace, "bundle"), resolved, + contract: readFileSync(join(REPO_ROLES, "assistant.md"), "utf8"), harness: HARNESS.pi, + session: { run: "assistant-run", launchedBy: business.human, workspace, + toolSocket: join(workspace, "tools.sock"), turnMarker: join(workspace, "turn.done"), + coordinator: { instance: other } } }); // Untrusted/session input cannot override the config. + const prompt = readFileSync(bundle.files.prompt, "utf8"); + prompts.push(prompt); + assert.match(prompt, /Launched by: jason\./); + assert.ok(prompt.includes(`Configured coordinator: instance key "${coordinator}"`)); + assert.ok(!prompt.includes(`Configured coordinator: instance key "${other}"`)); + if (expectedName) assert.ok(prompt.includes(`configured identity name: "${expectedName}"`)); + assert.equal(bundle.manifest.resolvedDigest, resolved.digest); + assert.equal(bundle.manifest.files["prompt.md"], sha(bundle.files.prompt)); + + const storeDir = join(workspace, "store"); mkdirSync(storeDir, { mode: 0o700 }); + const store = new Store(storeDir); + t.after(() => store.close()); + const broker = new Broker({ store, businesses: { acme: { ...business, + roles: Object.fromEntries(Object.entries(business.roles).map(([key, entry]) => + [key, { ...entry, authority: resolve(key).limits.authority }])) } } }); + const owner = broker.bindHuman({ business: "acme", human: business.human, via: "cli", outsideAgent: true }); + const call = (cap, verb, args = {}) => broker.request(cap, { verb, args }); + const project = call(owner, "project.create", { name: "Work" }).id; + const thread = call(owner, "thread.create", { project, name: "Origin" }).id; + const assistant = broker.bindLaunch({ business: "acme", role: "assistant", run: "assistant-run", harness: "pi", thread }); + call(assistant, "role.claim"); + assert.deepEqual(call(assistant, "agents"), [], "no coordinator or other non-thread role is running"); + assert.equal(store.get("SELECT count(*) n FROM role_claims WHERE role=?", coordinator).n, 0, "the coordinator has never claimed a run"); + const source = call(owner, "thread.send", { thread, body: "Build a timer" }); + const sent = call(assistant, "message.send", { to: resolved.coordinator.instance, body: "Build a timer", in_reply_to: source.id }); + assert.equal(store.get("SELECT to_role FROM messages WHERE id=?", sent.id).to_role, coordinator); + assert.equal(sent.request, source.request); + assert.throws(() => call(assistant, "message.send", { to: other, body: "Bypass", in_reply_to: source.id }), { code: "message-recipient-refused" }); + assert.throws(() => call(assistant, "message.send", { to: "chief-of-staff", body: "Guess" }), { code: "unknown-role" }); + } + assert.notDeepEqual(records[0], records[1]); + assert.notEqual(records[0].digest, records[1].digest); + assert.notEqual(prompts[0], prompts[1]); +}); + +test("an assistant without a configured launcher reports missing coordinator context", (t) => { + const resolved = resolvedFor(t, "assistant", (doc) => { delete doc.launch; doc.roles.assistant = { definition: "assistant" }; }); + assert.equal(resolved.coordinator, null); + const b = build(t, resolved); + assert.match(readFileSync(b.files.prompt, "utf8"), /Configured coordinator: none\. Report the missing business launcher configuration/); +}); + function build(t, resolved, extra = {}) { const dir = scratch(t); const workspace = join(dir, "ws"); diff --git a/roles/assistant.md b/roles/assistant.md index 19a4fe575..19c9efc03 100644 --- a/roles/assistant.md +++ b/roles/assistant.md @@ -18,9 +18,11 @@ sessions. - Talk with the user. Answer what they ask, say plainly what you don't know and keep their requests in their own words. - Send each request for the business to the chief of staff as a message, - addressed to the business's configured chief of staff instance, using - its instance key or name. Keep the user's words as they gave them, and - name the request each message carries. + addressed to the exact instance key in the generated `Configured coordinator` + line under `This session`. Use that key as send_message.to for requests + and rulings, even when the chief is not running. If no coordinator is + configured, report that blocker to the user. Keep the user's words as they + gave them, and name the request each message carries. - Relay each ruling the user gives verbatim to the chief of staff, naming whose ruling it is and which role it concerns. Don't summarize it, paraphrase it or add to it. Don't send it to that role yourself. From 1bd0077391f1925db5e66796b98540e48f13bfc2 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 5 Nov 2026 20:45:03 -0600 Subject: [PATCH 04/32] fix(harness): mark model activity before final answers (#797) --- BUILD-LOG.md | 9 ++ adapters/README.md | 9 ++ adapters/claude/adapter.sh | 9 +- adapters/claude/stream.mjs | 72 +++++++++ docs/SESSIONS.md | 2 + packages/harness/src/first-output.mjs | 3 +- packages/harness/src/pi-extension.mjs | 11 ++ packages/harness/src/runner.mjs | 2 + packages/harness/tests/bundle.test.mjs | 6 +- .../harness/tests/claude-session.test.mjs | 25 ++- packages/harness/tests/creative.test.mjs | 2 +- .../tests/fixtures/claude-activity.mjs | 42 +++++ .../harness/tests/model-activity.test.mjs | 153 ++++++++++++++++++ packages/harness/tests/skills.test.mjs | 2 +- packages/model-proxy/README.md | 7 +- .../plugins/harness/claude-code/index.mjs | 4 +- packages/plugins/tests/harness.test.mjs | 6 +- 17 files changed, 346 insertions(+), 18 deletions(-) create mode 100644 adapters/claude/stream.mjs create mode 100644 packages/harness/tests/fixtures/claude-activity.mjs create mode 100644 packages/harness/tests/model-activity.test.mjs diff --git a/BUILD-LOG.md b/BUILD-LOG.md index 0fc793057..1de8e9c14 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -6193,3 +6193,12 @@ Before: clean isolated checkout at e9271841f3af23bf2a72aa17d9d67a1ec5abe017; no After: personally authored three-file skills/ms-ux-improvement coordinator package, complementary to existing frontend execution guidance; final candidate-02 content manifest b93d7836ed7c70db62386192d6e1db5c4f867f841aaa7645c3c861def5a9b140. Retained 19 bounded clean-context behavioral samples / 186 raw files: six unguided baselines, seven candidate-01 samples including ready-review control, six candidate-02 samples. Candidate-01's two unnecessary frozen-method recovery conditions and already-correct control remain recorded; narrowed wording and final tests show no extra gate, without causal/statistical improvement claim. Fresh package-focused skill-creator/resource validation passed for workspace, frozen snapshot and scratch copy; patch/whitespace, arithmetic, integrity, unchanged baseline and append-only ownership checks passed. Entry 637 words (2,210 total) and one-model synthetic/procedural-scope limits retained as author concerns. Exact source, patch, hashes, commands, raw evidence and independent-review brief: /mnt/storage/scratch/mosaic-lead/skill-ux-improvement/author/run-20261105T022125Z/HANDOFF.md. Author task complete; independent source approval pending. No application suites, commits, forge/queue writes, publication, installation, runtime activation or protected actions. 2026-11-04 | #818 / Gitea #1845 canonical skill integration verified | Exact3 reviewed Markdown files match source hashes; canonical skill-creator validator and whitespace checks pass. Independent review APPROVE/no findings,14 focused checks and a fresh bounded forward-use handoff retained. All prior canonical and author log bytes preserved. Package is available in source by explicit path; no managed/personal installation, activation, publication or user-acceptance claim. MVP activation remains pinned to parentbd8120318, independent of this skill commit. + + +## 2026-11-05 — MVP 3 coder first-activity watchdog correction (#797), implementation handoff + +Before: isolated branch fix/mvp3-coder-stall-797 starts at 05fdfef9ea1fdddf58c725c04f229ca8559fc054. Claude text-mode stdout appeared only at the final answer, so the 120-second first-output watchdog could stop productive thinking/tool work (D2 in planning-gate-fix-r1-report.md). Explicit child charter authorizes a local implementation/test/commit only; queue next codex refused this linked worktree, with no workaround or queue mutation. + +After: Claude's private stream-json reader marks real upstream assistant events and emits only the successful final text, matching real CLI newline semantics byte for byte. Pi's existing extension also marks model text/thinking/tool deltas; mock remains a test-only immediate echo. Both reuse the existing exclusive 0600 proxy-first-output marker with fixed output\n content; init/status, synthetic CLI errors, tool results, stderr and workspace writes do not count. Runner supplies only its existing run directory; helper gets a read-only harness mount and bundle source digest. No timeout, schema, role authority, credential, proxy management or live state changed. Silent runs still stop with proxy-stall; interrupted first/resumed turns emit no partial answer or successful new session ID. + +Verification: Node v24.22.1 with PATH pinned to /mnt/storage/scratch/tmp/z62zsJ/node24/node_modules/node-linux-x64/bin and fresh TMPDIR from mktemp -d /mnt/storage/scratch/tmp/XXXXXX. Final full 28-file command: 376 passed, 0 failed/cancelled/skipped/todo, test-concurrency=4 (coder-stall-final-r2.tap). Includes full launcher/model-stall, harness runner/bundle, seat session/sandbox, real Claude/Pi adapter, confinement, installer/copy, source-digest and import-closure checks. Delayed tool/thinking/header/assistant fixtures use 0.8-second injected watchdog and 1.4-second pre-answer work. Initial tests retained: missing test import and failed-result exit handling, real text-mode trailing-newline difference, then a combined-gate grandchild-reaping assertion race; all corrected and full final gate green. Whitespace and baseline log-prefix preservation checks pass. Report, exact commands, SHA256 manifest, scans and all TAP receipts: /mnt/storage/scratch/mosaic-lead/mvp3/coder-stall-fix-r1-report.md and coder-stall-* siblings. Local commit follows these green suites. Independent exact-commit review, CI/integration and deployment/live GLM acceptance remain with the lead; no push, stash, fleet operation or external message. diff --git a/adapters/README.md b/adapters/README.md index 772e7d31a..4bda20b31 100644 --- a/adapters/README.md +++ b/adapters/README.md @@ -76,6 +76,7 @@ session's workspace and its own process group. Same contract, plus: | `MOSAIC_POLICY_FILE`, `MOSAIC_TOOLS_FILE` | The bundle's gate policy and typed tools. | | `MOSAIC_TOOL_SOCKET` | The runner's tool socket, which the typed tools call. | | `MOSAIC_TURN_MARKER` | `pi`: the extension writes it at `agent_end`; a pi turn that exits 0 without it failed. | +| `MOSAIC_MODEL_ACTIVITY_DIR` | Managed runner's existing writable run directory. Trusted model streaming events publish the fixed `output\n` token to `proxy-first-output` (0600, exclusive create). No event contents or credentials enter it. Claude's private stream reader and Pi's extension signal thinking, tool-use and text activity before final stdout; init/status, tool results and workspace writes do not count. | | `MOSAIC_EXTENSIONS` | `pi` only: extension files loaded with `-e`; a missing one exits 2. The pi module names two: the gate and typed tools extension, then the unslop check's `tools/unslop-hook/extension.ts` (row 66). | | `MOSAIC_UNSLOP_LOG` | The unslop check's JSONL log, `/unslop.jsonl`, which the runner sets for both adapters and reads after each turn (row 66). | | `MOSAIC_CLAUDE_SETTINGS`, `MOSAIC_CLAUDE_MCP_CONFIG` | `claude` only: the bundle's gate hook and MCP server; required. | @@ -85,6 +86,14 @@ session's workspace and its own process group. Same contract, plus: The layers each adapter relies on, and what they don't cover, are in `packages/harness/README.md`. +Claude uses `--output-format stream-json --verbose --include-partial-messages`. +The adapter's `stream.mjs` consumes those events and prints only the successful +final result, with the same newline as the CLI's text format. Malformed or +incomplete streams fail the turn; a failed or interrupted first turn still +does not persist a reusable session ID. The helper is mounted read-only and +its digest is recorded in the launch bundle. Pi retains print mode and signals +activity through its existing extension. Mock remains a test-only echo. + ### A role's skills (row 64) A managed session's skills reach both adapters as text in the bundle's diff --git a/adapters/claude/adapter.sh b/adapters/claude/adapter.sh index 84079b338..963792ea9 100644 --- a/adapters/claude/adapter.sh +++ b/adapters/claude/adapter.sh @@ -11,6 +11,7 @@ # wrapped as `timeout -k 2 10 || exit 2`, and the tool limit (--tools). # --bare is never passed: it turns settings hooks off. set -eu +STREAM="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/stream.mjs" need() { eval "v=\${$1:-}" @@ -60,7 +61,9 @@ fi # Flags: # -p one-shot: print the answer and exit -# --output-format text stdout is the answer only +# --output-format stream-json private model events; stream.mjs emits only +# --verbose --include-partial-messages the final result on stdout and marks +# first model activity before long tool work # --system-prompt-file the generated prompt replaces the default, given # as a file so no local user reads it in # /proc//cmdline (row 213); absolute, since @@ -84,8 +87,8 @@ fi # text in the system prompt (row 64) ALLOWED=mcp__mosaic [ -z "${MOSAIC_TOOLS:-}" ] || ALLOWED="$MOSAIC_TOOLS,mcp__mosaic" -"$CLAUDE" -p "$MOSAIC_REQUEST" \ - --output-format text \ +node "$STREAM" "$CLAUDE" -p "$MOSAIC_REQUEST" \ + --output-format stream-json --verbose --include-partial-messages \ --system-prompt-file "$PROMPT_FILE" \ --model "$MOSAIC_MODEL" \ --effort "$MOSAIC_THINKING" \ diff --git a/adapters/claude/stream.mjs b/adapters/claude/stream.mjs new file mode 100644 index 000000000..30a06f606 --- /dev/null +++ b/adapters/claude/stream.mjs @@ -0,0 +1,72 @@ +// Consume Claude Code's SDK event stream privately. Only the successful result +// reaches stdout, ensuring a trailing newline like --output-format text. +import { spawn } from 'node:child_process'; +import { firstOutputMarker } from '../../packages/harness/src/first-output.mjs'; + +const mark = process.env.MOSAIC_MODEL_ACTIVITY_DIR + ? firstOutputMarker(process.env.MOSAIC_MODEL_ACTIVITY_DIR) : () => {}; +const [binary, ...args] = process.argv.slice(2); +const child = spawn(binary, args, { stdio: ['ignore', 'pipe', 'inherit'] }); +let pending = '', answer, resultSeen = false, resultFailed = false, failed = false, killTimer; +const fail = () => { + if (failed) return; + failed = true; + // Never put event bodies, provider errors or credentials in diagnostics. + process.stderr.write('claude adapter: invalid or incomplete model stream\n'); + child.kill('SIGTERM'); + killTimer = setTimeout(() => child.kill('SIGKILL'), 5000).unref(); +}; +function event(line) { + if (!line.trim()) return; + const item = JSON.parse(line); + const model = item.type === 'stream_event' ? item.event : null; + // Init/status, hooks, user/tool results and arbitrary local writes aren't + // model activity. message_start is the upstream assistant response header; + // partial blocks also cover models that first respond with thinking/tools. + const start = model?.type === 'message_start' && model.message?.role === 'assistant'; + const block = model?.type === 'content_block_start' + && ['text', 'thinking', 'tool_use'].includes(model.content_block?.type); + const delta = model?.type === 'content_block_delta' + && ['text_delta', 'thinking_delta', 'input_json_delta'].includes(model.delta?.type); + const assistant = item.type === 'assistant' && !item.error + && item.message?.model !== '' && item.message?.role === 'assistant' + && item.message.content?.some(c => ['text', 'thinking', 'tool_use'].includes(c.type)); + if (start || block || delta || assistant) mark('activity'); + if (item.type === 'result') { + if (resultSeen) throw Error(); + resultSeen = true; + // Let a normal CLI failure exit on its own, retaining its exit code. + resultFailed = item.subtype !== 'success' || !!item.is_error; + if (!resultFailed) { + if (typeof item.result !== 'string') throw Error(); + answer = item.result; + } + } +} +child.stdout.setEncoding('utf8'); +child.stdout.on('data', chunk => { + if (failed) return; + try { + pending += chunk; + let end; + while ((end = pending.indexOf('\n')) !== -1) { + if (end > 16 * 1024 * 1024) throw Error(); + event(pending.slice(0, end)); + pending = pending.slice(end + 1); + } + if (pending.length > 16 * 1024 * 1024) throw Error(); + } catch { fail(); } +}); +child.on('error', fail); +child.on('close', (code, signal) => { + clearTimeout(killTimer); + if (!failed && pending) { + try { event(pending); } catch { fail(); } + } + if (failed || resultFailed || code !== 0 || signal || answer === undefined) { + if (!failed && code === 0 && !signal) fail(); + process.exitCode = code || 2; + } else { + process.stdout.write(answer.endsWith('\n') ? answer : answer + '\n'); + } +}); diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index 3a717e1c2..84c45f841 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -2064,3 +2064,5 @@ are never rewritten or removed; corrections are new entries. 2026-11-05 | codex-ux-skill-author | Vikunja #818 UX improvement skill author handoff | Candidate-02 three-file package personally authored; package/copy/patch/arithmetic/integrity checks passed; 19 synthetic behavioral samples and all 186 raw artifacts retained with candidate-01 issue/correction and limits. Exact manifest b93d7836ed7c70db62386192d6e1db5c4f867f841aaa7645c3c861def5a9b140; evidence /mnt/storage/scratch/mosaic-lead/skill-ux-improvement/author/run-20261105T022125Z/HANDOFF.md. Independent source review pending; no commit, app suites, forge/queue writes, installation, activation or publication. 2026-11-04 | Mos, skill integrator | #818 / Gitea #1845 | Integrated exact independently approved ms-ux-improvement candidate02 into source;3hashes/validator/whitespace pass and author histories preserved. Separate commit from MVP; its activation target staysbd8120318. No install, activation, publication or live action. + +2026-11-05 | Codex implementation child | MVP 3 #797 D2 coder-stall fix on fix/mvp3-coder-stall-797 from 05fdfef9e | Claude private streaming activity marker + answer-only stdout; Pi model-event coverage; silent/interrupted controls preserved. Final Node 24.22.1 full 28-file gate: 376 pass, 0 fail/skip/cancel. Queue refused linked worktree; no workaround. Local commit only, no push/stash/live actions. Report: /mnt/storage/scratch/mosaic-lead/mvp3/coder-stall-fix-r1-report.md; independent review remains with lead. diff --git a/packages/harness/src/first-output.mjs b/packages/harness/src/first-output.mjs index db3b19cee..23cf6f710 100644 --- a/packages/harness/src/first-output.mjs +++ b/packages/harness/src/first-output.mjs @@ -1,6 +1,7 @@ import { writeFileSync, openSync, closeSync, linkSync, unlinkSync } from 'node:fs'; import { join } from 'node:path'; -// The host needs an output-exists signal, never the output or a model credential. +// The host needs a model-activity signal, never event/output text or a credential. +// The historical filename/token stays stable for the host watchdog protocol. export function firstOutputMarker(runDir) { let seen = false; return chunk => { diff --git a/packages/harness/src/pi-extension.mjs b/packages/harness/src/pi-extension.mjs index 5647f0f52..e538b0efb 100644 --- a/packages/harness/src/pi-extension.mjs +++ b/packages/harness/src/pi-extension.mjs @@ -11,6 +11,7 @@ import { readFileSync, writeFileSync } from "node:fs"; import { decide } from "./gate.mjs"; import { callTool, replyText } from "./tools.mjs"; +import { firstOutputMarker } from "./first-output.mjs"; function need(name) { const value = process.env[name]; @@ -25,6 +26,16 @@ export default function (pi) { const marker = need("MOSAIC_TURN_MARKER"); if (policy.harness !== "pi" || !Array.isArray(policy.tools) || !Array.isArray(policy.typed)) throw new Error("mosaic extension: policy file is not a pi policy"); + // Print mode emits only the last answer, after all tool work. Use model + // streaming events, never tool output or workspace changes, for liveness. + const markActivity = process.env.MOSAIC_MODEL_ACTIVITY_DIR + ? firstOutputMarker(process.env.MOSAIC_MODEL_ACTIVITY_DIR) : () => {}; + pi.on("message_update", async (event) => { + if (event.message?.role === "assistant" && ["text_delta", "thinking_delta", "toolcall_start", "toolcall_delta"].includes(event.assistantMessageEvent?.type)) { + markActivity("activity"); + } + }); + pi.on("tool_call", async (event) => { const verdict = decide(policy, event.toolName, event.input); return verdict.allow ? undefined : { block: true, reason: verdict.reason }; diff --git a/packages/harness/src/runner.mjs b/packages/harness/src/runner.mjs index 9d870be6b..c827e2cf4 100644 --- a/packages/harness/src/runner.mjs +++ b/packages/harness/src/runner.mjs @@ -415,6 +415,8 @@ export function adapterEnv(session, env) { MOSAIC_PROVIDER: session.provider ?? "", MOSAIC_MODEL: session.model, ...session.harnessEnv, + // Fixed marker metadata from adapter model events, before answer stdout. + ...(session.runDir ? { MOSAIC_MODEL_ACTIVITY_DIR: session.runDir } : {}), // Row 66: after the module's variables, so the log is always the run's own. ...(session.runDir ? { MOSAIC_UNSLOP_LOG: unslopLog(session.runDir) } : {}), }; diff --git a/packages/harness/tests/bundle.test.mjs b/packages/harness/tests/bundle.test.mjs index f2e26e8c4..af2967406 100644 --- a/packages/harness/tests/bundle.test.mjs +++ b/packages/harness/tests/bundle.test.mjs @@ -4,7 +4,7 @@ import { createHash } from "node:crypto"; import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { BundleError, buildBundle, sessionModel, SOURCES } from "../src/bundle.mjs"; -import { HARNESS, REPO_SKILLS, resolvedFor, scratch } from "./helpers.mjs"; +import { HARNESS, REPO, REPO_SKILLS, resolvedFor, scratch } from "./helpers.mjs"; import { decide } from "../src/gate.mjs"; const UNSLOP_DIR = new URL("../../../tools/unslop-hook/", import.meta.url).pathname; @@ -150,7 +150,9 @@ test("a claude-code bundle adds the wrapped gate hook and the MCP config", (t) = assert.deepEqual(m.gateTimeout, { n: 10, k: 2, hook: 20 }); assert.ok(m.builtins.includes("Bash")); assert.ok(m.typed.every((n) => n.startsWith("mcp__mosaic__"))); - assert.deepEqual(Object.keys(m.sources).sort(), ["claudeGate", "gate", "mcpServer", "tools", "unslopCheck", "unslopHook", "unslopLists"]); + assert.equal(m.sources.claudeStream.path, join(REPO, "adapters/claude/stream.mjs")); + assert.equal(m.sources.claudeStream.sha256, sha(m.sources.claudeStream.path)); + assert.deepEqual(Object.keys(m.sources).sort(), ["claudeGate", "claudeStream", "gate", "mcpServer", "tools", "unslopCheck", "unslopHook", "unslopLists"]); // Row 66 (#1559): the unslop hooks run after a reply and before the next prompt, and never block. for (const [event, which] of [["Stop", "stop"], ["UserPromptSubmit", "prompt"]]) { assert.equal(settings.hooks[event].length, 1, event); diff --git a/packages/harness/tests/claude-session.test.mjs b/packages/harness/tests/claude-session.test.mjs index d9c45e446..8b24f1c5c 100644 --- a/packages/harness/tests/claude-session.test.mjs +++ b/packages/harness/tests/claude-session.test.mjs @@ -8,7 +8,7 @@ import { fixtureCacheEnv } from '../../../scripts/testing/lane-fixtures.mjs'; import { test } from "node:test"; import assert from "node:assert/strict"; import { spawn, spawnSync } from "node:child_process"; -import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { basename, dirname, join } from "node:path"; import { buildBundle } from "../src/bundle.mjs"; import { adapterEnv, unslopCounts, unslopLog, unslopNotes } from "../src/runner.mjs"; @@ -142,13 +142,29 @@ test("claude: a second turn resumes the first turn's session", { skip }, async ( assert.equal(readFileSync(join(s.sessionDir, "claude-session-id"), "utf8"), id); }); +for (const answer of ['Answer', ' Answer é 🦆\nsecond line\n', 'Answer\n\n']) { +test(`claude: streaming preserves the real CLI text answer byte for byte (${JSON.stringify(answer)})`, { skip }, async t => { + const { dir, workspace, env } = await session(t, [], [], () => answer); + const textAdapter = join(dir, 'text-adapter.sh'); + writeFileSync(textAdapter, readFileSync(ADAPTER, 'utf8') + .replace('node "$STREAM" "$CLAUDE"', '"$CLAUDE"') + .replace('--output-format stream-json --verbose --include-partial-messages', '--output-format text')); + const text = await turn({ ...env, MOSAIC_SESSION_DIR: join(dir, 'text-session') }, 'answer', workspace, textAdapter); + const stream = await turn(env, 'answer', workspace); + assert.equal(text.code, 0, text.stderr); + assert.equal(stream.code, 0, stream.stderr); + assert.equal(stream.stdout, text.stdout); + assert.ok(stream.stdout.includes(answer)); +}); +} + // --restricted is what keeps CLAUDE.md files and auto-memory out of the // session's prompt (README "Limits"). This one runs without claude: a // stand-in records the adapter's argv. test("claude adapter: --restricted is always passed", (t) => { const dir = scratch(t); mkdirSync(join(dir, "bin")); - writeFileSync(join(dir, "bin", "claude"), '#!/bin/sh\nprintf "%s\\n" "$@" > "$ARGV_OUT"\necho ok\n'); + writeFileSync(join(dir, "bin", "claude"), '#!/bin/sh\nprintf "%s\\n" "$@" > "$ARGV_OUT"\necho \'{"type":"result","subtype":"success","result":"ok"}\'\n'); chmodSync(join(dir, "bin", "claude"), 0o755); for (const f of ["prompt.md", "settings.json", "mcp.json"]) writeFileSync(join(dir, f), "{}"); const r = spawnSync("/bin/sh", [ADAPTER], { @@ -179,7 +195,7 @@ test("claude adapter: MOSAIC_CLAUDE_BIN wins over PATH and must be an absolute e const dir = scratch(t); mkdirSync(join(dir, "bin")); const stand = (p, word) => { - writeFileSync(p, `#!/bin/sh\necho ${word} > "$WHO_OUT"\necho ok\n`); + writeFileSync(p, `#!/bin/sh\necho ${word} > "$WHO_OUT"\necho \'{"type":"result","subtype":"success","result":"ok"}\'\n`); chmodSync(p, 0o755); return p; }; @@ -229,7 +245,7 @@ test("claude adapter: a directory pin exits 2 before it runs; a pin with a space const dir = scratch(t); mkdirSync(join(dir, "bin")); const stand = (p, word) => { - writeFileSync(p, `#!/bin/sh\necho ${word} > "$WHO_OUT"\necho ok\n`); + writeFileSync(p, `#!/bin/sh\necho ${word} > "$WHO_OUT"\necho \'{"type":"result","subtype":"success","result":"ok"}\'\n`); chmodSync(p, 0o755); return p; }; @@ -293,6 +309,7 @@ test("claude: CLAUDE.md files and auto-memory don't reach the model; without --r assert.deepEqual(await seen(ADAPTER), []); // The control: the same adapter without --restricted lets all four in. const loose = join(dir, "adapter-loose.sh"); + symlinkSync(join(REPO, "adapters/claude/stream.mjs"), join(dir, "stream.mjs")); writeFileSync(loose, readFileSync(ADAPTER, "utf8").replace(" --restricted \\\n", "")); assert.notEqual(readFileSync(loose, "utf8"), readFileSync(ADAPTER, "utf8")); assert.deepEqual(await seen(loose), Object.keys(plant)); diff --git a/packages/harness/tests/creative.test.mjs b/packages/harness/tests/creative.test.mjs index c3191f99e..d9ee76af8 100644 --- a/packages/harness/tests/creative.test.mjs +++ b/packages/harness/tests/creative.test.mjs @@ -174,7 +174,7 @@ function runAdapter(t, harness, b, { prompt = b.files.prompt, cwd } = {}) { const bin = join(b.dir, "bin"); mkdirSync(bin, { recursive: true }); const cli = join(bin, harness === "pi" ? "pi" : "claude"); - writeFileSync(cli, '#!/bin/sh\nprintf "%s\\0" "$@" > "$ARGV_OUT"\necho ok\n'); + writeFileSync(cli, '#!/bin/sh\nprintf "%s\\0" "$@" > "$ARGV_OUT"\necho \'{"type":"result","subtype":"success","result":"ok"}\'\n'); chmodSync(cli, 0o755); const s = { harness, provider: harness === "pi" ? "probe" : null, model: "claude-sonnet-5-5", workspace: b.workspace, sessionDir: join(b.dir, "session"), toolSocket: join(b.dir, "tools.sock"), turnMarker: join(b.dir, "turn.done"), bundle: { ...b.files, prompt }, harnessEnv: b.env }; const env = { ...adapterEnv(s, { PATH: `${bin}:/usr/bin:/bin`, HOME: join(b.dir, "home") }), ARGV_OUT: join(b.dir, "argv"), MOSAIC_REQUEST: "x" }; diff --git a/packages/harness/tests/fixtures/claude-activity.mjs b/packages/harness/tests/fixtures/claude-activity.mjs new file mode 100644 index 000000000..7b20928e3 --- /dev/null +++ b/packages/harness/tests/fixtures/claude-activity.mjs @@ -0,0 +1,42 @@ +// No network: a Claude CLI stand-in that distinguishes text and stream modes. +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +const args = process.argv.slice(2); +const answer = ' Answer é 🦆\nsecond line\n'; +const mode = process.env.FAKE_CLAUDE_MODE ?? 'tool'; +writeFileSync(join(process.env.MOSAIC_WORKSPACE, 'cli-pid'), String(process.pid)); +const emit = item => process.stdout.write(JSON.stringify(item) + '\n'); +if (args[args.indexOf('--output-format') + 1] === 'text') { + process.stdout.write(answer.endsWith('\n') ? answer : answer + '\n'); +} else { + if (!args.includes('--verbose') || !args.includes('--include-partial-messages')) process.exit(9); + emit({ type: 'system', subtype: 'init', private: 'credential-canary' }); + emit({ type: 'user', message: { role: 'user', content: [{ type: 'tool_result', content: 'local work' }] } }); + emit({ type: 'assistant', error: 'api_error', message: { role: 'assistant', model: '', content: [{ type: 'text', text: 'local CLI error' }] } }); + // Local file writes and diagnostic text must never cancel the watchdog. + writeFileSync(join(process.env.MOSAIC_WORKSPACE, 'draft'), 'workspace-canary'); + process.stderr.write('fixture diagnostic\n'); + if (mode === 'malformed') { + process.stdout.write('invalid credential-canary\n'); + } else if (mode === 'missing') { + // Exit 0 without a result still fails the adapter. + } else if (mode === 'failed') { + emit({ type: 'result', subtype: 'error_during_execution', is_error: true, errors: ['credential-canary'] }); + process.exitCode = 7; + } else { + if (mode !== 'silent') { + const event = mode === 'thinking' + ? { type: 'content_block_delta', delta: { type: 'thinking_delta', thinking: 'private reasoning' } } + : mode === 'header' + ? { type: 'message_start', message: { role: 'assistant' } } + : mode === 'assistant' + ? null + : { type: 'content_block_start', content_block: { type: 'tool_use', id: 'tool-1', name: 'Write', input: {} } }; + if (event) emit({ type: 'stream_event', event }); + else emit({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'private draft' }] } }); + } + setTimeout(() => { + emit({ type: 'result', subtype: 'success', is_error: false, result: answer }); + }, mode === 'interrupt' || mode === 'silent' ? 30_000 : Number(process.env.FAKE_CLAUDE_DELAY ?? 100)); + } +} diff --git a/packages/harness/tests/model-activity.test.mjs b/packages/harness/tests/model-activity.test.mjs new file mode 100644 index 000000000..96812d558 --- /dev/null +++ b/packages/harness/tests/model-activity.test.mjs @@ -0,0 +1,153 @@ +// Real adapter and watchdog, with a fake CLI: no models, proxy or credentials. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { watchModelOutput } from '../../cli/src/model-stall.mjs'; +import { firstTurnMarker } from '../src/first-output.mjs'; +import { adapterEnv } from '../src/runner.mjs'; +import piExtension from '../src/pi-extension.mjs'; +import { REPO, scratch } from './helpers.mjs'; + +const ADAPTER = join(REPO, 'adapters/claude/adapter.sh'); +const FAKE = new URL('./fixtures/claude-activity.mjs', import.meta.url).pathname; +const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); +function fixture(t, mode = 'tool') { + const runDir = scratch(t), workspace = join(runDir, 'ws'); + mkdirSync(workspace); + for (const name of ['prompt', 'settings', 'mcp']) writeFileSync(join(runDir, name), '{}'); + const cli = join(runDir, 'claude'); + writeFileSync(cli, `#!/bin/sh\nexec '${process.execPath}' '${FAKE}' "$@"\n`, { mode: 0o700 }); + const session = { + runDir, workspace, sessionDir: join(runDir, 'session'), model: 'proxy-model', + bundle: { prompt: join(runDir, 'prompt') }, + harnessEnv: { MOSAIC_CLAUDE_SETTINGS: join(runDir, 'settings'), MOSAIC_CLAUDE_MCP_CONFIG: join(runDir, 'mcp'), MOSAIC_THINKING: 'high' }, + }; + const env = adapterEnv(session, { + PATH: `${dirname(process.execPath)}:/usr/bin:/bin`, MOSAIC_CLAUDE_BIN: cli, + MOSAIC_REQUEST: 'write a file', FAKE_CLAUDE_MODE: mode, + }); + return { runDir, workspace, env, cli }; +} +function start(t, f) { + const child = spawn('/bin/sh', [ADAPTER], { env: f.env, stdio: ['ignore', 'pipe', 'pipe'], detached: true }); + const kill = () => { try { process.kill(-child.pid, 'SIGTERM'); } catch {} }; + let stdout = Buffer.alloc(0), stderr = ''; + child.stdout.on('data', b => { stdout = Buffer.concat([stdout, b]); }); + child.stderr.on('data', b => { stderr += b; }); + const done = new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', (code, signal) => resolve({ code, signal, stdout, stderr })); + }); + t.after(async () => { kill(); await done; }); + return { child, done, kill, stdout: () => stdout }; +} + +for (const mode of ['tool', 'thinking', 'header', 'assistant']) { + test(`Claude ${mode} activity survives a short watchdog window before final stdout`, { timeout: 10_000 }, async t => { + const f = fixture(t, mode); + f.env.FAKE_CLAUDE_DELAY = '1400'; + firstTurnMarker(f.runDir)(); + const turn = start(t, f), receipts = []; + let stops = 0; + const watch = watchModelOutput({ runDir: f.runDir, seconds: 0.8, armAtFirstTurn: true, keyId: '0123456789ab', + stop: async () => { stops++; turn.kill(); }, receipt: r => receipts.push(r) }); + t.after(watch.cancel); + await watch.done; + assert.equal(stops, 0); + assert.equal(turn.stdout().length, 0, 'model events stay private while the tool turn is in progress'); + assert.equal(readFileSync(join(f.runDir, 'proxy-first-output'), 'utf8'), 'output\n'); + assert.equal(statSync(join(f.runDir, 'proxy-first-output')).mode & 0o777, 0o600); + const result = await turn.done; + assert.equal(result.code, 0, result.stderr); + // Compare exact bytes against the same CLI's original text format. + const text = spawnSync(f.cli, ['--output-format', 'text'], { env: f.env }); + assert.equal(text.status, 0); + assert.deepEqual(result.stdout, text.stdout); + assert.deepEqual(receipts, []); + assert.match(readFileSync(join(f.runDir, 'session/claude-session-id'), 'utf8'), /^[0-9a-f-]{36}\n$/); + }); +} + +test('silent Claude is stopped with proxy-stall despite init, tool results, stderr and workspace writes', { timeout: 10_000 }, async t => { + const f = fixture(t, 'silent'), turn = start(t, f), receipts = []; + firstTurnMarker(f.runDir)(); + const watch = watchModelOutput({ runDir: f.runDir, seconds: 0.8, armAtFirstTurn: true, keyId: '0123456789ab', + stop: async () => turn.kill(), receipt: r => receipts.push(r) }); + t.after(watch.cancel); + await watch.done; + const result = await turn.done; + assert.notEqual(result.code, 0); + assert.equal(result.stdout.length, 0); + assert.equal(existsSync(join(f.workspace, 'draft')), true, 'workspace activity alone is insufficient'); + assert.equal(existsSync(join(f.runDir, 'proxy-first-output')), false); + assert.equal(existsSync(join(f.runDir, 'session/claude-session-id')), false); + assert.deepEqual(receipts, [{ action: 'proxy-stall', keyIds: ['0123456789ab'] }]); +}); + +for (const resumed of [false, true]) { +test(`interrupted Claude activity emits no partial answer, preserves session state and kills its CLI (resume=${resumed})`, { timeout: 10_000 }, async t => { + const f = fixture(t, 'interrupt'); + const sessionFile = join(f.runDir, 'session/claude-session-id'); + if (resumed) { + mkdirSync(join(f.runDir, 'session')); + writeFileSync(sessionFile, 'previous-session-id\n'); + } + const turn = start(t, f); + const deadline = Date.now() + 5000; + while (!existsSync(join(f.runDir, 'proxy-first-output'))) { + assert.ok(Date.now() < deadline, 'activity marker appears'); + await delay(10); + } + const pid = Number(readFileSync(join(f.workspace, 'cli-pid'), 'utf8')); + turn.kill(); + const result = await turn.done; + assert.notEqual(result.code, 0); + assert.equal(result.stdout.length, 0); + if (resumed) assert.equal(readFileSync(sessionFile, 'utf8'), 'previous-session-id\n'); + else assert.equal(existsSync(sessionFile), false); + // The CLI is a grandchild; its pipe can close just before the OS reaps it. + const reapDeadline = Date.now() + 5000; + for (;;) { + try { process.kill(pid, 0); } + catch (error) { assert.equal(error.code, 'ESRCH'); break; } + assert.ok(Date.now() < reapDeadline, 'interrupted CLI is reaped'); + await delay(10); + } +}); +} + +for (const mode of ['malformed', 'missing', 'failed']) { + test(`Claude ${mode} stream fails closed without raw event diagnostics or a session receipt`, async t => { + const f = fixture(t, mode), result = await start(t, f).done; + assert.notEqual(result.code, 0); + if (mode === 'failed') assert.equal(result.code, 7, 'preserve the CLI exit code'); + assert.equal(result.stdout.length, 0); + assert.doesNotMatch(result.stderr, /credential-canary/); + assert.equal(existsSync(join(f.runDir, 'session/claude-session-id')), false); + }); +} + +test('Pi extension marks real streamed thinking/tools/text, never local activity or user/tool messages', async t => { + const runDir = scratch(t); + writeFileSync(join(runDir, 'policy'), JSON.stringify({ harness: 'pi', tools: [], typed: [] })); + writeFileSync(join(runDir, 'tools'), '[]'); + const values = { MOSAIC_POLICY_FILE: join(runDir, 'policy'), MOSAIC_TOOLS_FILE: join(runDir, 'tools'), + MOSAIC_TOOL_SOCKET: join(runDir, 'sock'), MOSAIC_TURN_MARKER: join(runDir, 'done'), MOSAIC_MODEL_ACTIVITY_DIR: runDir }; + const prior = Object.fromEntries(Object.keys(values).map(k => [k, process.env[k]])); + Object.assign(process.env, values); + t.after(() => { for (const [k, v] of Object.entries(prior)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; } }); + for (const type of ['thinking_delta', 'toolcall_start', 'toolcall_delta', 'text_delta']) { + const handlers = new Map(); + piExtension({ on: (name, fn) => handlers.set(name, fn), registerTool() {} }); + const update = handlers.get('message_update'); + for (const role of ['user', 'toolResult']) await update({ message: { role }, assistantMessageEvent: { type } }); + await update({ message: { role: 'assistant' }, assistantMessageEvent: { type: 'start' } }); + assert.equal(existsSync(join(runDir, 'proxy-first-output')), false); + await update({ message: { role: 'assistant' }, assistantMessageEvent: { type } }); + assert.equal(readFileSync(join(runDir, 'proxy-first-output'), 'utf8'), 'output\n'); + // A new producer on each case proves each event type on its own. + unlinkSync(join(runDir, 'proxy-first-output')); + } +}); diff --git a/packages/harness/tests/skills.test.mjs b/packages/harness/tests/skills.test.mjs index 4baa3027d..e7581d8b9 100644 --- a/packages/harness/tests/skills.test.mjs +++ b/packages/harness/tests/skills.test.mjs @@ -174,7 +174,7 @@ test("each adapter passes the skills only in the prompt file, with skill discove mkdirSync(workspace); mkdirSync(join(dir, "bin")); const cli = join(dir, "bin", harness === "pi" ? "pi" : "claude"); - writeFileSync(cli, '#!/bin/sh\nprintf "%s\\0" "$@" > "$ARGV_OUT"\necho ok\n'); + writeFileSync(cli, '#!/bin/sh\nprintf "%s\\0" "$@" > "$ARGV_OUT"\necho \'{"type":"result","subtype":"success","result":"ok"}\'\n'); chmodSync(cli, 0o755); const resolved = resolvedFor(t, instance, (d) => (d.roles[instance].vars = { harness, model: "claude-opus-5-5", thinking: "high" })); assert.equal(resolved.skills.length > 0, instance === "reviewer", instance); diff --git a/packages/model-proxy/README.md b/packages/model-proxy/README.md index d9b2d0bdd..ac2f636f3 100644 --- a/packages/model-proxy/README.md +++ b/packages/model-proxy/README.md @@ -91,9 +91,12 @@ before reading config or touching the network/disk. The trusted launcher requests `modelPreflight` with business/role and receives exactly `{ baseUrl, key }`. That answer never passes through an agent socket or status API. A 401 or other non-success HTTP status refuses `proxy-key-refused`; -a failed connection refuses `proxy-down`. No stdout by the configured deadline +a failed connection refuses `proxy-down`. No model response activity by the configured deadline requests a bounded stop and records `proxy-stall` with key IDs. The runner writes -only a fixed token to `/proxy-first-output`, never stdout contents. A stop +only a fixed token to `/proxy-first-output`, never stdout contents. Managed +adapters also publish this token on trusted streamed model response events +(including thinking and tool-use), before their answer-only stdout. Startup +status, tool results and workspace file writes do not count. A stop failure records/refuses `proxy-stop-failed` and retains the key. P5 adds the bus-only `readUsageQueue()` management operation. Its fixed diff --git a/packages/plugins/harness/claude-code/index.mjs b/packages/plugins/harness/claude-code/index.mjs index 95f851a49..9f6367f85 100644 --- a/packages/plugins/harness/claude-code/index.mjs +++ b/packages/plugins/harness/claude-code/index.mjs @@ -16,6 +16,7 @@ export const interfaceVersion = 1; const REPO = fileURLToPath(new URL("../../../../", import.meta.url)); const GATE = fileURLToPath(new URL("../../../harness/src/claude-gate.mjs", import.meta.url)); const MCP_SERVER = fileURLToPath(new URL("../../../harness/src/mcp-server.mjs", import.meta.url)); +const STREAM = join(REPO, "adapters", "claude", "stream.mjs"); // Row 66 (#1559): the unslop check's hook script, which the Stop and UserPromptSubmit hooks run. It // logs to MOSAIC_UNSLOP_LOG, which the runner sets, and the runner tells the operator. const UNSLOP = Object.freeze({ @@ -80,6 +81,7 @@ export const countsAccount = () => true; // hook's files. export function mounts({ env }) { const unslop = Object.values(UNSLOP).map((src) => ({ src, why: "the unslop check the session's Stop and UserPromptSubmit hooks run" })); + unslop.push({ src: STREAM, why: "the adapter's private model stream reader" }); if (!env.MOSAIC_CLAUDE_BIN) return unslop; const real = realpathSync(env.MOSAIC_CLAUDE_BIN); return [...(real.startsWith("/usr/") ? [] : [{ src: real, dest: env.MOSAIC_CLAUDE_BIN, why: "the pinned Claude Code binary (MOSAIC_CLAUDE_BIN)" }]), ...unslop]; @@ -113,7 +115,7 @@ export function bundle({ dir, files: core, session, write, quote }) { mcpServers: { mosaic: { type: "stdio", command: node, args: [MCP_SERVER, core.tools, session.toolSocket] } }, }, null, 2) + "\n"), ]; - return { files, digests, sources: { claudeGate: GATE, mcpServer: MCP_SERVER, ...UNSLOP } }; + return { files, digests, sources: { claudeGate: GATE, mcpServer: MCP_SERVER, claudeStream: STREAM, ...UNSLOP } }; } export function env({ files, policy, session, proxy = null }) { diff --git a/packages/plugins/tests/harness.test.mjs b/packages/plugins/tests/harness.test.mjs index 0cf6c5966..5ff4bb558 100644 --- a/packages/plugins/tests/harness.test.mjs +++ b/packages/plugins/tests/harness.test.mjs @@ -74,8 +74,8 @@ test("row 65 claude-code preflight: a missing credential, then a missing or rela // The pinned binary outside /usr is mounted where the adapter looks for it, and the unslop hook's // files where the hook commands name them (row 66). const unslop = ["claude-hook.js", "unslop-check.js", "lists.json"].map(f => [join(REPO, "tools", "unslop-hook", f), undefined]); - assert.deepEqual(claude.mounts({ env: { MOSAIC_CLAUDE_BIN: bin } }).map(m => [m.src, m.dest]), [[bin, bin], ...unslop]); - assert.deepEqual(claude.mounts({ env: {} }).map(m => [m.src, m.dest]), unslop); + assert.deepEqual(claude.mounts({ env: { MOSAIC_CLAUDE_BIN: bin } }).map(m => [m.src, m.dest]), [[bin, bin], ...unslop, [join(REPO, "adapters/claude/stream.mjs"), undefined]]); + assert.deepEqual(claude.mounts({ env: {} }).map(m => [m.src, m.dest]), [...unslop, [join(REPO, "adapters/claude/stream.mjs"), undefined]]); }); // Row 68's claudeVersion precedence, moved here with the version (row 65): the manifest records @@ -137,7 +137,7 @@ test("row 65 a route's thinking reaches pi as --thinking and claude as --effort, const dir = scratch(t); mkdirSync(join(dir, "bin")); for (const name of ["pi", "claude"]) { - writeFileSync(join(dir, "bin", name), '#!/bin/sh\nprintf "%s\\n" "$@" > "$ARGV_OUT"\n'); + writeFileSync(join(dir, "bin", name), '#!/bin/sh\nprintf "%s\\n" "$@" > "$ARGV_OUT"\necho \'{"type":"result","subtype":"success","result":"ok"}\'\n'); chmodSync(join(dir, "bin", name), 0o755); } for (const f of ["prompt.md", "settings.json", "mcp.json"]) writeFileSync(join(dir, f), "{}"); From c47a67de8dbf885d9277011ddc1ff7266d4e3ade Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 5 Nov 2026 20:47:27 -0600 Subject: [PATCH 05/32] fix(bus): keep the coverage refusal when its notice fails (#797) Review M1 on 62bece4a9: a failed notice insert or route threw past #refused, replacing request-evidence-required and skipping its audit. The notice is now best-effort; its transaction still rolls back whole. Tests: insert and route failures keep code, audit and lifecycle; retry publishes once. Both red on 62bece4a9. --- packages/bus/src/broker.mjs | 16 ++++--- .../bus/tests/request-continuation.test.mjs | 45 +++++++++++++++++++ 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 743bf7ef2..00468591b 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -5459,13 +5459,17 @@ export class Broker { // advance rolls its transaction back on refusal. Publish the actionable PM // message afterwards, once per revision and coverage set, through the normal // bus route: the sandbox tool socket only exposes the refusal code. + // The notice is diagnostic only: if any step of it fails, its transaction rolls back + // and the original refusal and its audit event still go out. Retry publishes it. if (request.verb === 'request.advance' && e instanceof BusError && e.planningCoverage) { - const state = this.#requestJournal.readRequest(s.business, args.request); - const body = `Planning coverage for request ${state.request}, revision ${state.revision}:\n${e.reason}`; - this.#store.transaction(() => { - if (!this.#store.get('SELECT 1 FROM messages WHERE business=? AND from_role=? AND to_role=? AND body=?', s.business, NOTICE_ROLE, s.role, body)) - this.#continuationMessage(state, s.role, body, this.#continuationContext(state, s.role)); - }); + try { + const state = this.#requestJournal.readRequest(s.business, args.request); + const body = `Planning coverage for request ${state.request}, revision ${state.revision}:\n${e.reason}`; + this.#store.transaction(() => { + if (!this.#store.get('SELECT 1 FROM messages WHERE business=? AND from_role=? AND to_role=? AND body=?', s.business, NOTICE_ROLE, s.role, body)) + this.#continuationMessage(state, s.role, body, this.#continuationContext(state, s.role)); + }); + } catch { /* refusal below is the operation's result */ } } throw this.#refused(s, e instanceof BusError ? e : new BusError(e.code ?? 'request-failed'), request); }); diff --git a/packages/bus/tests/request-continuation.test.mjs b/packages/bus/tests/request-continuation.test.mjs index 80a767dea..2e0be804d 100644 --- a/packages/bus/tests/request-continuation.test.mjs +++ b/packages/bus/tests/request-continuation.test.mjs @@ -484,3 +484,48 @@ test('evidence already recorded while PM is absent does not duplicate its queued await f.bus.requestLifecycle.recover(); assert.equal(messages(f, intent.result.intent.key).filter(m => m.to_role === 'pm').length, before); }); + +// Review M1 (planning fix r1): a failed coverage notice must not replace the refusal. +const coverageNotices = db => db.prepare("SELECT * FROM messages WHERE from_role='_bus' AND body LIKE 'Planning coverage for request %' ORDER BY seq").all(); +const planningDb = async t => { + const { DatabaseSync } = await import('node:sqlite'); + const { join } = await import('node:path'); + const f = await requestFixture(t); const sub = await f.submit(); f.approve(sub); await f.activate(sub); + await f.bus.requestLifecycle.recover(); + const db = new DatabaseSync(join(f.f.dataRoot, 'bus/bus.sqlite')); t.after(() => db.close()); + return { f, db, args: { request: f.request, revision: f.journal.readRequest('demo', f.request).revision, op: 'm1' } }; +}; + +test('a failed notice insert keeps the coverage refusal, its audit and the lifecycle; retry publishes once', async t => { + const { f, db, args } = await planningDb(t); + const state = f.journal.readRequest('demo', f.request); + const events = f.store.all('SELECT * FROM request_events'), effects = f.store.all('SELECT * FROM request_effects'); + const refusals = () => db.prepare("SELECT count(*) n FROM events WHERE kind='action.refused'").get().n; + const before = refusals(); + db.exec("CREATE TRIGGER m1_fail_notice BEFORE INSERT ON messages WHEN NEW.from_role='_bus' AND NEW.body LIKE 'Planning coverage for request %' BEGIN SELECT RAISE(ABORT,'m1-publish-failed'); END"); + await assert.rejects(f.call('pm', 'request.advance', args), { code: 'request-evidence-required' }); + assert.equal(refusals(), before + 1); + assert.equal(coverageNotices(db).length, 0, 'no partial notice'); + assert.deepEqual(f.journal.readRequest('demo', f.request), state); + assert.deepEqual(f.store.all('SELECT * FROM request_events'), events); + assert.deepEqual(f.store.all('SELECT * FROM request_effects'), effects); + db.exec('DROP TRIGGER m1_fail_notice'); + await assert.rejects(f.call('pm', 'request.advance', args), { code: 'request-evidence-required' }); + assert.equal(coverageNotices(db).length, 1); +}); + +test('a failed notice route rolls back message and delivery and the socket still returns the coverage refusal', async t => { + const { Client } = await import('../src/client.mjs'); + const { f, db, args } = await planningDb(t); + const state = f.journal.readRequest('demo', f.request); + const deliveries = () => db.prepare('SELECT count(*) n FROM deliveries').get().n, before = deliveries(); + db.exec("CREATE TRIGGER m1_fail_route BEFORE INSERT ON deliveries WHEN NEW.op='routed' AND EXISTS(SELECT 1 FROM messages WHERE id=NEW.message AND from_role='_bus' AND body LIKE 'Planning coverage for request %') BEGIN SELECT RAISE(ABORT,'m1-route-failed'); END"); + const client = new Client({ path: f.bus.path, cap: f.caps.pm }); + await assert.rejects(client.call('request.advance', args), { code: 'request-evidence-required' }); + assert.equal(coverageNotices(db).length, 0, 'message rolls back with its failed route'); + assert.equal(deliveries(), before); + assert.deepEqual(await client.call('request.get', { request: f.request }), state); + db.exec('DROP TRIGGER m1_fail_route'); + await assert.rejects(client.call('request.advance', args), { code: 'request-evidence-required' }); + assert.equal(coverageNotices(db).length, 1); +}); From bd8340b9b8062fc31fe9aa8705e770ac1d459795 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 5 Nov 2026 20:53:44 -0600 Subject: [PATCH 06/32] feat(cli): add isolated ui797 acceptance support (#797) --- BUILD-LOG.md | 3 + docs/SESSIONS.md | 2 + packages/cli/live/README.md | 215 ++++++++++++++ packages/cli/live/budget-supervisor.mjs | 206 ++++++++++++++ packages/cli/live/budget-supervisor.test.mjs | 122 ++++++++ packages/cli/live/isolated-identity.mjs | 111 ++++++++ packages/cli/live/isolated-identity.test.mjs | 95 +++++++ packages/cli/live/support-common.mjs | 71 +++++ packages/cli/live/test-support.mjs | 15 + packages/cli/live/ui-browser.mjs | 279 +++++++++++++++++++ packages/cli/live/ui-browser.test.mjs | 91 ++++++ 11 files changed, 1210 insertions(+) create mode 100644 packages/cli/live/budget-supervisor.mjs create mode 100644 packages/cli/live/budget-supervisor.test.mjs create mode 100644 packages/cli/live/isolated-identity.mjs create mode 100644 packages/cli/live/isolated-identity.test.mjs create mode 100644 packages/cli/live/support-common.mjs create mode 100644 packages/cli/live/test-support.mjs create mode 100644 packages/cli/live/ui-browser.mjs create mode 100644 packages/cli/live/ui-browser.test.mjs diff --git a/BUILD-LOG.md b/BUILD-LOG.md index 0fc793057..1911f6310 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -6193,3 +6193,6 @@ Before: clean isolated checkout at e9271841f3af23bf2a72aa17d9d67a1ec5abe017; no After: personally authored three-file skills/ms-ux-improvement coordinator package, complementary to existing frontend execution guidance; final candidate-02 content manifest b93d7836ed7c70db62386192d6e1db5c4f867f841aaa7645c3c861def5a9b140. Retained 19 bounded clean-context behavioral samples / 186 raw files: six unguided baselines, seven candidate-01 samples including ready-review control, six candidate-02 samples. Candidate-01's two unnecessary frozen-method recovery conditions and already-correct control remain recorded; narrowed wording and final tests show no extra gate, without causal/statistical improvement claim. Fresh package-focused skill-creator/resource validation passed for workspace, frozen snapshot and scratch copy; patch/whitespace, arithmetic, integrity, unchanged baseline and append-only ownership checks passed. Entry 637 words (2,210 total) and one-model synthetic/procedural-scope limits retained as author concerns. Exact source, patch, hashes, commands, raw evidence and independent-review brief: /mnt/storage/scratch/mosaic-lead/skill-ux-improvement/author/run-20261105T022125Z/HANDOFF.md. Author task complete; independent source approval pending. No application suites, commits, forge/queue writes, publication, installation, runtime activation or protected actions. 2026-11-04 | #818 / Gitea #1845 canonical skill integration verified | Exact3 reviewed Markdown files match source hashes; canonical skill-creator validator and whitespace checks pass. Independent review APPROVE/no findings,14 focused checks and a fresh bounded forward-use handoff retained. All prior canonical and author log bytes preserved. Package is available in source by explicit path; no managed/personal installation, activation, publication or user-acceptance claim. MVP activation remains pinned to parentbd8120318, independent of this skill commit. + +2026-11-05 | #797 ui797 support implementation phase | Explicit lead charter from 05fdfef9ea1fdddf58c725c04f229ca8559fc054 on feat/ui797-support: local isolated OIDC/TLS, persistent CDP browser CLI and durable metered campaign supervisor only under packages/cli/live. Queue next refused linked worktree (exit 2); canonical queue/forge ownership remains with lead. Initial author tests identified explicit-config selection and browser evidence-count defects, corrected locally; real browser SSO and full focused gates in progress. No .53/live access, push, runtime/role/contract edits, attribution trailers or stash. +2026-11-05 | #797 ui797 support author candidate verified | Added local single-subject HTTPS OIDC and fixed TLS forwarder, persistent real-input Chromium CLI with private NSS trust, and external durable per-model metered-stop ledgers. Final focused run: 34 pass / 0 fail / 0 skip (identity 6, browser 5, budget 23); dependency regressions: 111 pass / 0 fail / 0 skip; supported processing CLI regressions: 4 pass / 0 fail / 0 skip. Initial configuration-selection, Basic credential decoding, browser readiness/count and shutdown issues were corrected; all final gates pass. Source remains confined to packages/cli/live plus append-only protocol records. Local commit authorized; independent exact-candidate review and supervised-service acceptance remain with lead. No .53/live activity, model spend, push or product-runtime/role/contract changes. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-r1-evidence/; report: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-r1-report.md. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index 3a717e1c2..4c93312ee 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -2064,3 +2064,5 @@ are never rewritten or removed; corrections are new entries. 2026-11-05 | codex-ux-skill-author | Vikunja #818 UX improvement skill author handoff | Candidate-02 three-file package personally authored; package/copy/patch/arithmetic/integrity checks passed; 19 synthetic behavioral samples and all 186 raw artifacts retained with candidate-01 issue/correction and limits. Exact manifest b93d7836ed7c70db62386192d6e1db5c4f867f841aaa7645c3c861def5a9b140; evidence /mnt/storage/scratch/mosaic-lead/skill-ux-improvement/author/run-20261105T022125Z/HANDOFF.md. Independent source review pending; no commit, app suites, forge/queue writes, installation, activation or publication. 2026-11-04 | Mos, skill integrator | #818 / Gitea #1845 | Integrated exact independently approved ms-ux-improvement candidate02 into source;3hashes/validator/whitespace pass and author histories preserved. Separate commit from MVP; its activation target staysbd8120318. No install, activation, publication or live action. +2026-11-05 | Codex ui797 implementation child | #797 isolated OIDC/TLS, browser CLI and campaign budget support from 05fdfef9e on feat/ui797-support | Author implementation/tests underway in lead-chartered worktree; queue refused linked worktree, no canonical queue writes or protected actions. +2026-11-05 | Codex ui797 implementation child | #797 feat/ui797-support author handoff | Three isolated test tools and focused tests ready for lead review; final unique gates 149 pass / 0 fail / 0 skip, real production-CLI browser OIDC sign-in included, metered-stop/ancestry limits documented. Local commit only; no remote/live activity, spend, role/contract or product runtime edits. diff --git a/packages/cli/live/README.md b/packages/cli/live/README.md index 205740c04..568d6637c 100644 --- a/packages/cli/live/README.md +++ b/packages/cli/live/README.md @@ -61,3 +61,218 @@ its own process group, verifies that exact PID is dead, records the stale lock's identity and removes only its lock/socket before restart. The database, WAL and SHM are preserved. A live operator must establish the same facts and preserve evidence before recovery; this harness does not change that production rule. + +## ui797 isolated acceptance support (#797) + +These three owner-run tools support a separately provisioned **test** instance. +They do not provision a business, tracker, provider proxy or service units. Keep +all runtime/evidence directories outside this checkout, the config directory and +the instance data root. Use owner-only directories (0700) and campaign files +(0600). No command targets .53 or installs/starts production services implicitly. +The test instance uses its own proxy; there is no shared-proxy attachment here. + +Requirements: the repository's pinned Node dependencies, Node with `node:sqlite`, +OpenSSL, Chromium, NSS `certutil`, and Linux Bubblewrap (`/usr/bin/bwrap`) when the +OS user's legacy `~/.pki/nssdb` exists. All examples assume a provisioner already +created/validated the named instance through `scripts/bootstrap.sh --instance +ui797`, with a matching `instance.json`. Never change HOME or clear agent markers. +For temporary test directories set `TMPDIR` using +`mktemp -d /mnt/storage/scratch/tmp/XXXXXX` (never `/tmp`). + +### Local identity and TLS + +In an approved setup shell, set `CFG` to the **test** config's absolute path and +`RUNROOT` to an existing owner-only runtime/evidence parent. The config's parent +must be named `mosaic-ui797`. The following creates a new identity directory, +private two-day CA/server certificates, a private RS256 signing key and the +0600 slot `/secrets/mosaic-stack/ui797-local`. Existing files refuse. +It prints the JSON block to insert into the test config, without changing that +config or printing the client secret: + +```sh +node packages/cli/live/isolated-identity.mjs prepare \ + --instance ui797 --config "$CFG" --owner ui797-owner \ + --runtime "$RUNROOT/identity" --tls-port 17443 +``` + +Add that exact `console.sso` block through the approved test-config setup step. +The public origin is `https://127.0.0.1:17443`, issuer +`https://127.0.0.1:17443/oidc/`. Only `ui797-agent-owner` can be issued, displayed +as `ui797-owner`. Authorization immediately redirects back with a random, +single-use 60-second code, state and S256 PKCE; signed ID tokens last 300 seconds. +There is no refresh grant, alternate subject or live identity-provider integration. + +Run these as separate normally supervised **test** services, not Console children +of a browser agent. Preserve the real production entry point and ancestry proof: + +```sh +node packages/cli/live/isolated-identity.mjs serve --runtime "$RUNROOT/identity" +``` + +```sh +MOSAIC_CONFIG="$CFG" NODE_EXTRA_CA_CERTS="$RUNROOT/identity/ca.pem" \ + node packages/webui/src/cli.mjs serve --business mvp3-ui797 \ + --port 17440 --board http://127.0.0.1:17439 --sso --send +``` + +The identity service binds only 127.0.0.1:17443 and forwards non-OIDC paths to +127.0.0.1:17440 with the original Host/Origin/cookies. It adds no subject headers. +The Console validates discovery, JWKS, PKCE, nonce, token signature and its normal +session/owner rules. A missing bus, business or ancestry proof still refuses. +No human/Console binding shortcut or TLS-verification disable flag is used. +Trust `ca.pem` via `NODE_EXTRA_CA_CERTS` only in these test services, never globally. +Certificates expire after two days; do not overwrite identity material on restart. + +### Step-by-step real browser CLI for tester agents + +`start` creates an exclusive evidence directory and fresh profile, then starts a +private Unix-socket daemon. Subsequent invocations reuse that daemon. Either set +`UI797_EVIDENCE` or pass `--evidence DIR` on each command. Only the exact loopback +origins `http://127.0.0.1:17440` and `https://127.0.0.1:17443` are accepted. Every +page/subresource redirect must stay on the selected origin. Other targets and +WebSockets are blocked; a blocked network attempt makes the session fail until +`stop`. The driver never contacts 7332 or mosaic.woltje.com. + +The browser trusts the test CA through a private NSS database under its evidence +directory. If Chromium would prefer the user's legacy NSS store, Bubblewrap +mounts the private database over that path **only inside the browser process's +mount namespace**. HOME and host trust files remain unchanged. Certificate +verification stays enabled; a different CA is rejected. Startup refuses when the +required trust setup or namespace is unavailable; it does not fall back to a +certificate-error exception. + +Exact commands for a tester (replace discovered refs and button names with the +current `snapshot` output): + +```sh +export UI797_EVIDENCE="$RUNROOT/browser-a" +node packages/cli/live/ui-browser.mjs start \ + --evidence "$UI797_EVIDENCE" --origin https://127.0.0.1:17443 \ + --ca "$RUNROOT/identity/ca.pem" +node packages/cli/live/ui-browser.mjs goto '/#chat' +node packages/cli/live/ui-browser.mjs snapshot +node packages/cli/live/ui-browser.mjs click --text Chat +node packages/cli/live/ui-browser.mjs type r1-123 'Build a Pomodoro timer.' +node packages/cli/live/ui-browser.mjs press Enter +node packages/cli/live/ui-browser.mjs wait --text 'Choose how to proceed' --timeout 120 +node packages/cli/live/ui-browser.mjs screenshot request-plan +node packages/cli/live/ui-browser.mjs click r1-456 +node packages/cli/live/ui-browser.mjs downloads +node packages/cli/live/ui-browser.mjs stop +``` + +With a sole provider, the initial page load signs in immediately via the normal +OIDC redirects. `snapshot` reports visible accessibility-tree text and interactive +roles/names/disabled states, with document-scoped stable refs. Snapshot again after +navigation; stale refs refuse. `click --text` needs exactly one enabled exact +accessible-name match. `type` focuses with a real mouse click and inserts text at +the current caret via CDP Input; it appends rather than clears. Supported keys: +Enter, Tab, Escape, Backspace, Delete, Home/End, PageUp/Down, arrows, Space and +letters/digits, with Ctrl/Control, Alt, Meta or Shift modifiers (for example +`press Ctrl+A`, `press Backspace` to clear a focused field). Clicks use +real CDP mouse input. No page-function calls or fetch/state mocks are injected. +`wait` is bounded to 120 seconds per invocation. No uncertain write is replayed. + +Each accepted action, including failed commands, start and stop, writes +`NNNNN-before.png`, `NNNNN-after.png` and one `actions.jsonl` receipt with result +status. The journal omits URLs, query strings, text inputs, headers, bodies and +cookies. Screenshots show page contents, so keep all evidence private and avoid +entering secrets. Snapshots omit input values. Downloads are saved under browser +GUIDs and listed with absolute paths, byte lengths and SHA-256; compare these +with the product's delivery receipt. Partial downloads refuse. Profiles/evidence +are retained on stop. An interrupted daemon is not automatically replaced or its +commands replayed: inspect receipts and use a new evidence directory for a new +explicitly authorized session. `stop` remains available after a refused network +request. These tools do not yet open downloaded artifacts in an offline target. + +### Durable metered campaign stop + +An owner creates a single 0600 `campaign.json` **before launching inference**: + +```json +{ + "version": 1, + "instance": "ui797", + "config": "/ABS/CONFIG/mosaic-ui797/config.json", + "business": "mvp3-ui797", + "owner": "ui797-owner", + "start": "2026-11-06T02:00:00.000Z", + "end": "2026-11-06T03:00:00.000Z", + "caps": [ + {"provider":"proxy","route":"glm","model":"glm-5.3-flash","tokens":10000000}, + {"provider":"proxy","route":"anthropic","model":"claude-opus-5-5","tokens":10000000}, + {"provider":"proxy","route":"anthropic","model":"claude-sonnet-5-5","tokens":10000000} + ] +} +``` + +Use the approved actual absolute config path, canonical UTC campaign start/end +and **all** actual model routes; the dates above illustrate the schema. Caps are +positive safe integers at most 10,000,000; tiny values allow zero-spend tests. +The test bus store must exist before initialization. Run the supervisor outside +model-writable settings/data in a normally supervised owner service: + +```sh +node packages/cli/live/budget-supervisor.mjs init \ + --campaign "$RUNROOT/campaign.json" --ledger "$RUNROOT/campaign-ledger" +node packages/cli/live/budget-supervisor.mjs serve \ + --campaign "$RUNROOT/campaign.json" --ledger "$RUNROOT/campaign-ledger" --interval 1 +``` + +On-demand commands (safe for a tester to request; a triggered halt still requires +the normal owner-service ancestry): + +```sh +node packages/cli/live/budget-supervisor.mjs check \ + --campaign "$RUNROOT/campaign.json" --ledger "$RUNROOT/campaign-ledger" +node packages/cli/live/budget-supervisor.mjs status \ + --campaign "$RUNROOT/campaign.json" --ledger "$RUNROOT/campaign-ledger" +``` + +`status` prints persisted totals from the last successful accounting check versus +each model's `tokens` cap, the end time and latch state. It does not refresh usage. +The supervisor opens only the pinned test bus SQLite store read-only, using the +same `turnRecords` accounting as token-limit (input + output + cacheWrite + +cacheRead). Proxy telemetry is excluded. A run's immutable bundle manifest supplies +its actual `proxyRoute`; fallback/drift/uncapped models refuse. Delivered/read +messages and ended runs detect missing completed usage. An in-flight begin waits +for its outcome; unknown/malformed completed usage stops the campaign. + +Each provider/route/model has a separate append-only ledger, durable across +rounds/restarts. Campaign bytes, root identity and store identity are pinned; +changed caps/epoch, rewritten/disappeared usage and damaged ledger files stop. +Initialization is exclusive. There is no reset, raise or resume command. A stale +`check.lock` after interruption requires owner diagnosis; never auto-reclaim it. +A changed target config refuses rather than halting an unverified data root. + +At any cap, deadline or accounting refusal, the supervisor fsyncs `stop.json` +before invoking the existing `mosaic halt --business ... --json` production CLI +with the selected test `MOSAIC_CONFIG`. The CLI's human-shell/ancestry proof stays +intact. A refusal/unknown stop is retained as a failed `halt-N.json` receipt and +exits nonzero; the latch remains. If storage cannot persist a latch or receipt, it +still attempts halt and reports the storage failure; durable evidence then needs +owner repair before any further campaign work. Do not strip markers, direct-bind a principal, +write processing state, or treat that receipt as quiescence. A properly installed +owner service must perform/confirm halt. `serve` reasserts a latched halt each tick; +it never resumes. Manual Resume is outside the campaign protocol. + +This is a **metered stop**, not strict inference admission or a hard provider +ceiling. Concurrent/in-flight work, transcript accounting and the 0.1–10-second +check interval can overshoot a cap. Install the native token-limit controls as +well, retain accounting evidence, and use only the lead-approved metered-stop +acceptance envelope. These tools add no model-dispatch hook or network quota gate. + +Focused verification (no model spend, no remote access): + +```sh +TMPDIR=$(mktemp -d /mnt/storage/scratch/tmp/XXXXXX) \ + node --test --test-concurrency=1 packages/cli/live/*.test.mjs +``` + +Run these files sequentially: their integration fixtures exclusively bind the +fixed test ports 17440/17443 and fail if another process owns them. The browser +SSO test starts the unchanged production `cli.mjs serve --sso` without `--send`; +it proves genuine sign-in, not authenticated broker writes or real-model quality. +The budget tests inject a tiny cap and a fake halt at the supervisor boundary; +they also prove the real CLI refuses an agent environment. No live business is +halted by these tests. diff --git a/packages/cli/live/budget-supervisor.mjs b/packages/cli/live/budget-supervisor.mjs new file mode 100644 index 000000000..6563d5596 --- /dev/null +++ b/packages/cli/live/budget-supervisor.mjs @@ -0,0 +1,206 @@ +#!/usr/bin/env node +// Metered stop, with accounting/in-flight delay. Never a provider-side hard ceiling. +import { DatabaseSync } from 'node:sqlite'; +import { createHash } from 'node:crypto'; +import { closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readdirSync, rmdirSync, writeFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { turnRecords } from '../../bus/src/usage.mjs'; +import { exclusive, fail, isMain, options, outsideRuntime, plainPath, privateDir, readPrivate, ROOT, testInstance } from './support-common.mjs'; +const kinds = ['input','output','cacheWrite','cacheRead']; +const digest = x => createHash('sha256').update(typeof x === 'string' ? x : JSON.stringify(x)).digest('hex'); +const identity = c => JSON.stringify([c.provider,c.route,c.model]); +const filename = c => digest(identity(c)) + '.jsonl'; +const canonicalTime = s => typeof s === 'string' && Number.isFinite(Date.parse(s)) && new Date(s).toISOString() === s; +function campaignAt(path) { + privateDir(dirname(path)); + const text = readPrivate(path), c = JSON.parse(text); + if (c.version !== 1 || c.owner !== 'ui797-owner' || !/^[a-z][a-z0-9-]{1,63}$/.test(c.business ?? '') || !canonicalTime(c.start) || !canonicalTime(c.end) || c.end <= c.start || + !Array.isArray(c.caps) || !c.caps.length || c.caps.length > 20) fail('campaign-invalid'); + const keys = new Set(); + for (const cap of c.caps) { + if (Object.keys(cap).sort().join(',') !== 'model,provider,route,tokens' || !['proxy','anthropic','zai','openai'].includes(cap.provider) || + !['glm','anthropic','codex'].includes(cap.route) || !/^[a-zA-Z0-9][a-zA-Z0-9._:/-]{0,127}$/.test(cap.model ?? '') || + !Number.isSafeInteger(cap.tokens) || cap.tokens < 1 || cap.tokens > 10000000 || keys.has(identity(cap))) fail('campaign-cap-invalid'); + keys.add(identity(cap)); + } + return { c, hash:digest(text) }; +} +function binding(c) { + const { system } = testInstance(c.instance,c.config); + const st = lstatSync(system.dataRoot), dbPath = join(system.dataRoot,'bus/bus.sqlite'); plainPath(dbPath); + const db = lstatSync(dbPath); + if (!db.isFile() || db.uid !== process.getuid()) fail('test-store-required'); + return { instance:c.instance,config:c.config,dataRoot:system.dataRoot,business:c.business,owner:c.owner,rootIdentity:[st.dev,st.ino],storeIdentity:[db.dev,db.ino] }; +} +function checkBinding(pinned, c) { + const b = binding(c); + if (JSON.stringify(b) !== JSON.stringify(pinned)) fail('campaign-instance-changed'); + return b; +} +function writeJournal(file, row) { + readPrivate(file); + const fd = openSync(file,'a'); + try { writeFileSync(fd,JSON.stringify(row)+'\n'); fsyncSync(fd); } finally { closeSync(fd); } +} +function flushDir(dir) { const fd = openSync(dir,'r'); try { fsyncSync(fd); } finally { closeSync(fd); } } +export function initialize({ campaign, ledger }) { + const { c,hash } = campaignAt(campaign), b = binding(c); + outsideRuntime(campaign,{dataRoot:b.dataRoot},c.config); + outsideRuntime(ledger,{dataRoot:b.dataRoot},c.config); privateDir(ledger,true); + for (const cap of c.caps) exclusive(join(ledger,filename(cap)),JSON.stringify({ version:1,cap })+'\n'); + exclusive(join(ledger,'campaign.json'),JSON.stringify({ version:1,hash,binding:b,campaign:c })+'\n'); + flushDir(ledger); + return { initialized:true,ledger }; +} +export function readAccounting({ dataRoot,business,start,end }) { + const db = new DatabaseSync(join(dataRoot,'bus/bus.sqlite'),{readOnly:true}); + try { + db.exec('BEGIN'); + if (db.prepare("SELECT value FROM meta WHERE key='schema_version'").get()?.value !== '3s') fail('test-store-schema-refused'); + const store = { all:(sql,...args) => db.prepare(sql).all(...args) }; + const records = turnRecords(store,business); + const raw = store.all("SELECT * FROM usage_records WHERE business=? AND source<>'@proxy/cliproxy-queue' ORDER BY seq",business); + const completed = store.all(`SELECT DISTINCT d.holder_run run,m.id turn,d.at FROM deliveries d JOIN messages m ON m.id=d.message + WHERE m.business=? AND d.op='read' AND d.holder_run IS NOT NULL AND d.at>=?`,business,start); + const ended = store.all("SELECT subject run,at FROM events WHERE business=? AND kind='session.ended' AND at>=?",business,start); + db.exec('COMMIT'); + const phases = new Map(raw.map(r => [JSON.stringify([r.run,r.turn,r.source]),r.phase])); + const endedRuns = new Set(ended.map(r=>r.run)); + for (const r of raw) if (!['begin','end'].includes(r.phase) || !['known','unknown'].includes(r.status)) fail('usage-malformed'); + const interrupted = store.all(`SELECT DISTINCT d.holder_run run,m.id turn FROM deliveries d JOIN messages m ON m.id=d.message JOIN events e ON e.subject=d.holder_run AND e.business=m.business AND e.kind='session.ended' WHERE m.business=? AND d.op='delivered' AND e.at>=?`,business,start); + for (const t of interrupted) if (!records.some(r=>r.run===t.run && r.turn===t.turn && r.status==='known')) fail('usage-missing'); + for (const r of records) { + if (!canonicalTime(r.at)) fail('usage-malformed'); + if (r.at < start) continue; + // An in-flight begin is not unknown COMPLETED usage. It is checked on completion/end. + if (phases.get(JSON.stringify([r.run,r.turn,r.source])) !== 'end' && !endedRuns.has(r.run) && !completed.some(x=>x.run===r.run && x.turn===r.turn)) continue; + if (r.status !== 'known') fail('usage-unknown'); + } + const selected = records.filter(r => r.at >= start && r.status === 'known'); + for (const t of completed) if (!selected.some(r=>r.run===t.run && r.turn===t.turn)) fail('usage-missing'); + // Ended runs with begun turns must have a completed record for every source. + const rows = selected.map(r => { + if (!/^r[0-9a-f]{12}$/.test(r.run)) fail('usage-run-invalid'); + const manifest = JSON.parse(readPrivate(join(dataRoot,'launches',business,r.run,'bundle/manifest.json'))); + if (manifest.business !== business || manifest.run !== r.run || manifest.instance !== r.instance || manifest.provider !== r.provider || manifest.model !== r.model || manifest.route !== 'primary') fail('usage-route-drift'); + if (!['glm','anthropic','codex'].includes(manifest.proxyRoute)) fail('usage-route-unknown'); + return { ...r,route:manifest.proxyRoute }; + }); + return rows; + } finally { db.close(); } +} +export function supportedHalt(b, env=process.env) { + // This supervisor must be installed as a genuine owner service. Never strip agent markers, + // bindHuman/bindConsole, forge ancestry, or write control/processing.json directly. + const p = spawnSync(process.execPath,[join(ROOT,'packages/cli/src/cli.mjs'),'halt','--business',b.business,'--json'],{ + env:{...env,MOSAIC_CONFIG:b.config},encoding:'utf8',timeout:40000,maxBuffer:1024*1024, + }); + if (p.status !== 0) fail('supported-halt-refused-or-unknown'); + let result; try { result=JSON.parse(p.stdout); } catch { fail('supported-halt-invalid-reply'); } + if (result.halted !== true || result.quiescent !== true) fail('supported-halt-not-quiescent'); + return { halted:true,quiescent:true,generation:result.generation }; +} +function journal(ledger,cap) { + const text = readPrivate(join(ledger,filename(cap))); + if (!text.endsWith('\n')) fail('ledger-truncated'); + const [header,...entries] = text.trimEnd().split('\n').map(JSON.parse); + if (header.version !== 1 || JSON.stringify(header.cap) !== JSON.stringify(cap)) fail('ledger-header-changed'); + const seen = new Map(); let total = 0; + for (const e of entries) { + if (typeof e.key !== 'string' || typeof e.digest !== 'string' || !Number.isSafeInteger(e.tokens) || e.tokens < 0 || seen.has(e.key)) fail('ledger-malformed'); + seen.set(e.key,e); total += e.tokens; + if (!Number.isSafeInteger(total)) fail('ledger-overflow'); + } + return { entries:seen,total }; +} +function loadLedger(ledger) { + privateDir(ledger); const p=JSON.parse(readPrivate(join(ledger,'campaign.json'))); + if (p.version !== 1 || !p.binding || !p.campaign) fail('ledger-manifest-invalid'); + return p; +} +export async function check({ campaign,ledger }, { now=Date.now, read=readAccounting, halt=supportedHalt }={}) { + let p, integrityFailure=false; + try { p=loadLedger(ledger); } catch { + // With a damaged manifest, only the owner campaign and validated TEST marker may identify + // the target. Never silently rebuild the accounting epoch or continue with a zero ledger. + privateDir(ledger); const {c,hash}=campaignAt(campaign); + p={campaign:c,hash,binding:binding(c)}; integrityFailure=true; + } + const c=p.campaign; + // Pin the target before any halt. A changed config cannot redirect the stop into live data. + checkBinding(p.binding,c); outsideRuntime(ledger,{dataRoot:p.binding.dataRoot},c.config); + outsideRuntime(campaign,{dataRoot:p.binding.dataRoot},c.config); + const lock=join(ledger,'check.lock'); mkdirSync(lock,{mode:0o700}); + try { + let reason=integrityFailure?'ledger-manifest-invalid':null, totals=[]; + const stopFile=join(ledger,'stop.json'); + if (existsSync(stopFile)) { + reason='stop-already-latched'; + try { JSON.parse(readPrivate(stopFile)); } catch { reason='stop-latch-invalid'; } + } + try { + if (campaignAt(campaign).hash !== p.hash) fail('campaign-changed'); + const clock=now(); if (clock < Date.parse(c.start)) fail('campaign-not-started'); if (clock>=Date.parse(c.end)) fail('campaign-ended'); + if (!reason) { + const records=await read({...p.binding,start:c.start,end:c.end}), observed=new Set(); + const accounts=c.caps.map(cap=>({cap,...journal(ledger,cap)})); + for (const r of records) { + if (!canonicalTime(r.at) || r.at!Number.isSafeInteger(r[k]) || r[k]<0)) fail('usage-unknown-or-malformed'); + const a=accounts.find(a=>identity(a.cap)===identity(r)); if (!a) fail('usage-uncapped-route-model'); + const key=JSON.stringify([r.run,r.turn,r.source]); if (observed.has(key)) fail('usage-duplicate'); observed.add(key); + const tokens=kinds.reduce((sum,k)=>sum+r[k],0); if (!Number.isSafeInteger(tokens)) fail('usage-overflow'); + const hash=digest(r), previous=a.entries.get(key); + if (previous && previous.digest !== hash) fail('usage-rewritten'); + if (!previous) { + const e={key,digest:hash,tokens}; writeJournal(join(ledger,filename(a.cap)),e); a.entries.set(key,e); a.total+=tokens; + if (!Number.isSafeInteger(a.total)) fail('ledger-overflow'); + } + } + for (const a of accounts) for (const key of a.entries.keys()) if (!observed.has(key)) fail('usage-disappeared'); + totals=accounts.map(a=>({...a.cap,total:a.total})); + if (totals.some(a=>a.total>=a.tokens)) reason='model-cap-reached'; + } + } catch(e) { reason=/^[a-z-]{1,80}$/.test(e.message)?e.message:'accounting-failed'; } + if (reason) { + let storageError=null; + try { + if (!existsSync(stopFile)) { exclusive(stopFile,JSON.stringify({version:1,at:new Date(now()).toISOString(),reason,binding:p.binding,totals})+'\n'); flushDir(ledger); } + } catch { storageError='stop-latch-not-durable'; } + // Always attempt halt, even when the evidence volume cannot persist the latch. + let result,error; + try { checkBinding(p.binding,c); result=await halt(p.binding); } catch { error='supported-halt-refused-or-unknown'; } + try { + const attempt=readdirSync(ledger).filter(n=>/^halt-[0-9]+\.json$/.test(n)).length+1; + exclusive(join(ledger,`halt-${attempt}.json`),JSON.stringify({at:new Date(now()).toISOString(),ok:!error,result:result??null,error:error??null,storageError})+'\n'); flushDir(ledger); + } catch { storageError='stop-receipt-not-durable'; } + if (storageError) fail(storageError); + if (error) fail(error); + return {stopped:true,reason,totals,halt:result}; + } + return {stopped:false,totals}; + } finally { rmdirSync(lock); } +} +export function status({ campaign,ledger }) { + const p=loadLedger(ledger); checkBinding(p.binding,p.campaign); + outsideRuntime(campaign,{dataRoot:p.binding.dataRoot},p.campaign.config); + if (campaignAt(campaign).hash!==p.hash) fail('campaign-changed'); + const totals=p.campaign.caps.map(cap=>({...cap,total:journal(ledger,cap).total})); + return { stopped:existsSync(join(ledger,'stop.json')),end:p.campaign.end,totals }; +} +export async function main(argv) { + const command=argv.shift(),o=options(argv,['--campaign','--ledger','--interval']); + const opts={campaign:o['--campaign'],ledger:o['--ledger']}; + if (command==='init') return initialize(opts); + if (command==='status') return status(opts); + if (command==='check') return check(opts); + if (command!=='serve') fail('use init, check, serve or status'); + const interval=Number(o['--interval']??1); if (!Number.isFinite(interval) || interval<0.1 || interval>10) fail('interval must be 0.1..10 seconds'); + let running=true; for(const signal of ['SIGTERM','SIGINT']) process.once(signal,()=>{running=false;}); + while(running) { + // Reassert a latched halt on EVERY tick, so a later manual Resume cannot clear this campaign. + const result=await check(opts); console.log(JSON.stringify(result)); + await new Promise(r=>setTimeout(r,interval*1000)); + } +} +if (isMain(import.meta.url)) main(process.argv.slice(2)).then(r=>{if(r!==undefined)console.log(JSON.stringify(r,null,2));}).catch(e=>{console.error(e.message);process.exitCode=1;}); diff --git a/packages/cli/live/budget-supervisor.test.mjs b/packages/cli/live/budget-supervisor.test.mjs new file mode 100644 index 000000000..4aa922ab1 --- /dev/null +++ b/packages/cli/live/budget-supervisor.test.mjs @@ -0,0 +1,122 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdirSync,writeFileSync,readFileSync,readdirSync,unlinkSync,symlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { initialize,check,status,readAccounting,supportedHalt } from './budget-supervisor.mjs'; +import { fixture } from './test-support.mjs'; +import { Store } from '../../bus/src/store.mjs'; +const NOW=Date.parse('2026-11-06T02:30:00.000Z'); +const row=(extra={})=>({at:'2026-11-06T02:29:00.000Z',run:'r123456789abc',turn:'t1',source:'transcript',instance:'assistant',provider:'proxy',route:'glm',model:'glm-5.3-flash',status:'known',input:1,output:1,cacheWrite:1,cacheRead:1,...extra}); +function setup(t,tokens=5) { + const f=fixture(t),store=new Store(f.dataRoot);t.after(()=>store.close()); + const campaign=join(f.root,'campaign.json'),ledger=join(f.root,'ledger'); + const c={version:1,instance:'ui797',config:f.config,business:'mvp3-ui797',owner:'ui797-owner',start:'2026-11-06T02:00:00.000Z',end:'2026-11-06T03:00:00.000Z',caps:[{provider:'proxy',route:'glm',model:'glm-5.3-flash',tokens},{provider:'proxy',route:'anthropic',model:'claude-opus-5-5',tokens:6}]}; + writeFileSync(campaign,JSON.stringify(c),{mode:0o600});initialize({campaign,ledger}); + const halts=[],halt=b=>{halts.push(b);return {halted:true,quiescent:true,generation:'test'};}; + return {...f,store,campaign,ledger,c,halts,halt,opts:{campaign,ledger}}; +} +test('tiny cap counts all four token kinds per model, persists/deduplicates across checks and latches entire campaign',async t=>{ + const f=setup(t);const opts={now:()=>NOW,halt:f.halt,read:()=>[row()]}; + assert.equal((await check(f.opts,opts)).stopped,false); + assert.equal((await check(f.opts,opts)).totals[0].total,4);assert.equal(f.halts.length,0); + const stopped=await check(f.opts,{...opts,read:()=>[row(),row({turn:'t2',input:0,output:1,cacheRead:0,cacheWrite:0})]}); + assert.equal(stopped.reason,'model-cap-reached');assert.equal(stopped.totals[0].total,5);assert.equal(stopped.totals[1].total,0); + assert.equal(f.halts[0].dataRoot,f.dataRoot); + assert.equal(status(f.opts).stopped,true);assert.equal(status(f.opts).totals[0].total,5); + assert.equal((await check(f.opts,opts)).reason,'stop-already-latched');assert.equal(f.halts.length,2); + assert.throws(()=>initialize(f.opts)); +}); +for(const [name,records,reason] of [ + ['unknown',[row({status:'unknown'})],'usage-unknown-or-malformed'], + ['malformed',[row({input:-1})],'usage-unknown-or-malformed'], + ['unsafe integer',[row({input:Number.MAX_SAFE_INTEGER+1})],'usage-unknown-or-malformed'], + ['route drift',[row({route:'anthropic'})],'usage-uncapped-route-model'], + ['unlisted model',[row({model:'another-model'})],'usage-uncapped-route-model'], + ['duplicate',[row(),row()],'usage-duplicate'], +])test(`${name} latches before the test-only halt`,async t=>{ + const f=setup(t); + const result=await check(f.opts,{now:()=>NOW,read:()=>records,halt:b=>{assert.ok(readFileSync(join(f.ledger,'stop.json')).length);return f.halt(b);}}); + assert.equal(result.reason,reason);assert.equal(f.halts.length,1); +}); +test('campaign deadline, immutable owner cap and corrupt/missing ledger all stop without auto reset',async t=>{ + for(const variant of ['deadline','raise','malformed','missing']) { + const sub=await t.test(variant,async t=>{ + const f=setup(t);let now=NOW; + if(variant==='deadline')now=Date.parse(f.c.end); + if(variant==='raise'){f.c.caps[0].tokens=10;writeFileSync(f.campaign,JSON.stringify(f.c));} + if(variant==='malformed')writeFileSync(join(f.ledger,readdirSync(f.ledger).find(n=>n.endsWith('.jsonl'))),'{'); + if(variant==='missing')unlinkSync(join(f.ledger,readdirSync(f.ledger).find(n=>n.endsWith('.jsonl')))); + const result=await check(f.opts,{now:()=>now,read:()=>[],halt:f.halt});assert.equal(result.stopped,true);assert.equal(f.halts.length,1); + }); + } +}); +test('halt refusal keeps the durable stop and records an unknown halt, with no resume or raise',async t=>{ + const f=setup(t,1); + await assert.rejects(check(f.opts,{now:()=>NOW,read:()=>[row()],halt:()=>{throw new Error('human-required');}}),/supported-halt-refused/); + assert.equal(status(f.opts).stopped,true); + assert.equal(JSON.parse(readFileSync(join(f.ledger,'halt-1.json'))).ok,false); + await check(f.opts,{now:()=>NOW,halt:f.halt});assert.equal(f.halts.length,1); +}); +test('usage removal or rewrite is refused rather than refilling or double-counting',async t=>{ + const f=setup(t,100);await check(f.opts,{now:()=>NOW,read:()=>[row()],halt:f.halt}); + const result=await check(f.opts,{now:()=>NOW,read:()=>[row({input:0})],halt:f.halt});assert.equal(result.reason,'usage-rewritten'); + const g=setup(t,100);await check(g.opts,{now:()=>NOW,read:()=>[row()],halt:g.halt}); + assert.equal((await check(g.opts,{now:()=>NOW,read:()=>[],halt:g.halt})).reason,'usage-disappeared'); +}); +function insert(f,phase='end',status='known',source='transcript') { + f.store.run(`INSERT INTO usage_records(at,business,run,instance,provider,model,turn,source,phase,status,input,output,cache_write,cache_read) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + row().at,f.c.business,row().run,'assistant','proxy','glm-5.3-flash','t1',source,phase,status,...(status==='known'?[1,1,1,1]:[null,null,null,null])); + const manifest=join(f.dataRoot,'launches',f.c.business,row().run,'bundle');mkdirSync(manifest,{recursive:true,mode:0o700}); + writeFileSync(join(manifest,'manifest.json'),JSON.stringify({business:f.c.business,run:row().run,instance:'assistant',provider:'proxy',model:'glm-5.3-flash',proxyRoute:'glm',route:'primary'}),{mode:0o600}); +} +test('actual read-only bus store source matches token-limit records and excludes proxy telemetry',async t=>{ + const f=setup(t,100);insert(f);insert(f,'end','known','@proxy/cliproxy-queue'); + const r=readAccounting({dataRoot:f.dataRoot,business:f.c.business,start:f.c.start,end:f.c.end});assert.equal(r.length,1);assert.equal(r[0].route,'glm'); + assert.equal((await check(f.opts,{now:()=>NOW,halt:f.halt})).totals[0].total,4); + assert.equal(f.store.get('SELECT count(*) n FROM usage_records').n,2); +}); +test('a completed message with no usage records stops; an in-flight begin waits, an ended unknown turn stops',async t=>{ + const f=setup(t,100);insert(f,'begin'); + assert.equal((await check(f.opts,{now:()=>NOW,halt:f.halt})).stopped,false); + f.store.run(`INSERT INTO events(id,at,business,kind,subject,body) VALUES(?,?,?,?,?,?)`,'ended',row().at,f.c.business,'session.ended',row().run,'{}'); + assert.equal((await check(f.opts,{now:()=>NOW,halt:f.halt})).reason,'usage-unknown'); + const g=setup(t,100); + g.store.run(`INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES(?,?,?,?,?,?,?,?)`,'t1',row().at,g.c.business,'chief','chief-run','assistant','note','work'); + g.store.run(`INSERT INTO deliveries(message,at,op,holder_run) VALUES(?,?,?,?)`,'t1',row().at,'read',row().run); + assert.equal((await check(g.opts,{now:()=>NOW,halt:g.halt})).reason,'usage-missing'); +}); +test('completed unknown source and fallback manifest fail closed against the real store',async t=>{ + const f=setup(t,100);insert(f,'end','unknown'); + assert.equal((await check(f.opts,{now:()=>NOW,halt:f.halt})).reason,'usage-unknown'); + const g=setup(t,100);insert(g); + const path=join(g.dataRoot,'launches',g.c.business,row().run,'bundle/manifest.json'),m=JSON.parse(readFileSync(path));m.route='fallback';writeFileSync(path,JSON.stringify(m)); + assert.equal((await check(g.opts,{now:()=>NOW,halt:g.halt})).reason,'usage-route-drift'); +}); +test('changed target config never directs a halt to a different root',async t=>{ + const f=setup(t,1),other=join(f.root,'other');mkdirSync(other);writeFileSync(join(other,'instance.json'),JSON.stringify({instanceVersion:1,name:'ui797'})); + const config=JSON.parse(readFileSync(f.config));config.dataRoot=other;writeFileSync(f.config,JSON.stringify(config)); + await assert.rejects(check(f.opts,{now:()=>NOW,read:()=>[row()],halt:f.halt}));assert.equal(f.halts.length,0); +}); +test('normal supported halt refuses an agent environment, preserving its markers',t=>{ + const f=setup(t);assert.throws(()=>supportedHalt({config:f.config,dataRoot:f.dataRoot,business:f.c.business},{...process.env,MOSAIC_AGENT_RUN:'test-agent'}),/supported-halt-refused/); +}); +test('a corrupt ledger manifest stops with the validated owner campaign, never recreates accounting',async t=>{ + const f=setup(t,100);writeFileSync(join(f.ledger,'campaign.json'),'{'); + const result=await check(f.opts,{now:()=>NOW,read:()=>[],halt:f.halt});assert.equal(result.reason,'ledger-manifest-invalid');assert.equal(f.halts.length,1); + assert.equal(readFileSync(join(f.ledger,'campaign.json'),'utf8'),'{'); +}); +test('an invalid existing stop latch still invokes the supported test halt',async t=>{ + const f=setup(t,100);mkdirSync(join(f.ledger,'stop.json'),{mode:0o700}); + const result=await check(f.opts,{now:()=>NOW,read:()=>[],halt:f.halt});assert.equal(result.reason,'stop-latch-invalid');assert.equal(f.halts.length,1); +}); +test('model-writable campaign paths refuse initialization',t=>{ + const f=fixture(t),store=new Store(f.dataRoot);t.after(()=>store.close()); + const campaign=join(f.dataRoot,'campaign.json');writeFileSync(campaign,JSON.stringify({version:1,instance:'ui797',config:f.config,business:'mvp3-ui797',owner:'ui797-owner',start:'2026-11-06T02:00:00.000Z',end:'2026-11-06T03:00:00.000Z',caps:[{provider:'proxy',route:'glm',model:'glm-5.3-flash',tokens:1}]}),{mode:0o600}); + assert.throws(()=>initialize({campaign,ledger:join(f.root,'ledger')}),/outside/); +}); + +test('a failed durable-latch write still halts and returns an explicit storage failure',async t=>{ + const f=setup(t,1);symlinkSync(join(f.root,'missing-stop-target'),join(f.ledger,'stop.json')); + await assert.rejects(check(f.opts,{now:()=>NOW,read:()=>[row()],halt:f.halt}),/stop-latch-not-durable/); + assert.equal(f.halts.length,1);assert.equal(JSON.parse(readFileSync(join(f.ledger,'halt-1.json'))).storageError,'stop-latch-not-durable'); +}); diff --git a/packages/cli/live/isolated-identity.mjs b/packages/cli/live/isolated-identity.mjs new file mode 100644 index 000000000..7711e26d5 --- /dev/null +++ b/packages/cli/live/isolated-identity.mjs @@ -0,0 +1,111 @@ +#!/usr/bin/env node +// Deliberately fake identity, real browser code flow and production OIDC validation. +import { createHash, createPrivateKey, createPublicKey, randomBytes } from 'node:crypto'; +import { chmodSync, mkdirSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { createServer } from 'node:https'; +import { request } from 'node:http'; +import { rsaKey, jwt } from '../../webui/tests/helpers/fake-oidc.mjs'; +import { checkConsole } from '../../webui/src/sso-config.mjs'; +import { exclusive, fail, isMain, options, outsideRuntime, plainPath, privateDir, readPrivate, testInstance } from './support-common.mjs'; +export const SUBJECT = 'ui797-agent-owner', OWNER = 'ui797-owner'; +const ORIGIN = 'https://127.0.0.1:17443', ISSUER = `${ORIGIN}/oidc/`, CLIENT = 'mosaic-ui797', SLOT = 'ui797-local'; +export function prepare({ instance, config, owner, runtime, tlsPort }) { + if (owner !== OWNER || String(tlsPort) !== '17443') fail('only ui797-owner and test TLS port 17443 supported'); + const { system } = testInstance(instance, config); + outsideRuntime(runtime, system, config); + privateDir(runtime, true); // exclusive: never replace a previous identity or secret + const secretDir = join(dirname(config), 'secrets', 'mosaic-stack'); + plainPath(secretDir); mkdirSync(secretDir, { recursive: true, mode: 0o700 }); + const secret = randomBytes(32).toString('base64url'); + exclusive(join(secretDir, SLOT), secret + '\n'); + const openssl = args => { + const r = spawnSync('openssl', args, { cwd: runtime, encoding: 'utf8' }); + if (r.status !== 0) fail('test certificate generation failed'); + }; + // All intermediates stay in the exclusive private runtime, never the repository. + exclusive(join(runtime, 'extensions.cnf'), 'basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\nsubjectAltName=IP:127.0.0.1\n'); + openssl(['req','-x509','-newkey','rsa:2048','-nodes','-days','2','-subj','/CN=ui797 private test CA','-addext','basicConstraints=critical,CA:TRUE','-keyout','ca.key','-out','ca.pem']); + openssl(['req','-new','-newkey','rsa:2048','-nodes','-subj','/CN=127.0.0.1','-keyout','tls.key','-out','tls.csr']); + openssl(['x509','-req','-in','tls.csr','-CA','ca.pem','-CAkey','ca.key','-CAcreateserial','-days','2','-extfile','extensions.cnf','-out','tls.pem']); + for (const name of ['ca.key','ca.pem','tls.key','tls.pem','tls.csr','ca.srl']) chmodSync(join(runtime, name), 0o600); + const signing = rsaKey('ui797-rsa'); + exclusive(join(runtime, 'signing.pem'), signing.privateKey.export({ type: 'pkcs8', format: 'pem' })); + const console = checkConsole({ sso: { publicOrigin: ORIGIN, providers: [{ id: SLOT, issuer: ISSUER, clientId: CLIENT, clientSecret: { slot: SLOT }, algs: ['RS256'], subjects: [{ sub: SUBJECT, name: OWNER }] }] } }); + exclusive(join(runtime, 'identity.json'), JSON.stringify({ version: 1, instance, config, owner, console }) + '\n'); + return { console }; +} +export async function serve(runtime, { clock = Date.now } = {}) { + privateDir(runtime); + const state = JSON.parse(readPrivate(join(runtime, 'identity.json'))); + const { system } = testInstance(state.instance, state.config); + outsideRuntime(runtime, system, state.config); + if (state.version !== 1 || state.owner !== OWNER || JSON.stringify(state.console) !== JSON.stringify(checkConsole(state.console)) || state.console.sso.publicOrigin !== ORIGIN || state.console.sso.providers.length !== 1 || state.console.sso.providers[0].issuer !== ISSUER || state.console.sso.providers[0].id !== SLOT || state.console.sso.providers[0].clientId !== CLIENT || state.console.sso.providers[0].clientSecret.slot !== SLOT || JSON.stringify(state.console.sso.providers[0].subjects) !== JSON.stringify([{ sub: SUBJECT, name: OWNER }])) fail('test identity metadata refused'); + const secret = readPrivate(join(dirname(state.config), 'secrets/mosaic-stack', SLOT)).trim(); + const privateKey = createPrivateKey(readPrivate(join(runtime, 'signing.pem'))); + const jwk = { ...createPublicKey(privateKey).export({ format: 'jwk' }), kid: 'ui797-rsa', use: 'sig', alg: 'RS256' }; + const codes = new Map(); + const server = createServer({ key: readPrivate(join(runtime, 'tls.key')), cert: readPrivate(join(runtime, 'tls.pem')) + readPrivate(join(runtime, 'ca.pem')) }, (req, res) => { + res.setHeader('cache-control', 'no-store'); + const json = (status, body) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); }; + if (req.headers.host !== '127.0.0.1:17443') return json(403, { error: 'test_host_required' }); + const url = new URL(req.url, ORIGIN); + const path = url.pathname; + if (!path.startsWith('/oidc/')) { + // Fixed loopback upstream, original public Host/Origin and browser cookies intact. + // No subject/authentication headers are manufactured here. + const upstream = request({ hostname: '127.0.0.1', port: 17440, method: req.method, path: req.url, headers: req.headers }, reply => { + res.writeHead(reply.statusCode, reply.headers); reply.pipe(res); + }); + upstream.setTimeout(30000, () => upstream.destroy()); + upstream.on('error', () => { if (!res.headersSent) res.writeHead(502); res.end(); }); + req.on('aborted', () => upstream.destroy()); req.pipe(upstream); return; + } + if (req.method === 'GET' && path === '/oidc/.well-known/openid-configuration') return json(200, { + issuer: ISSUER, authorization_endpoint: `${ISSUER}authorize`, token_endpoint: `${ISSUER}token`, jwks_uri: `${ISSUER}jwks`, + response_types_supported: ['code'], subject_types_supported: ['public'], id_token_signing_alg_values_supported: ['RS256'], + token_endpoint_auth_methods_supported: ['client_secret_basic'], code_challenge_methods_supported: ['S256'], + }); + if (req.method === 'GET' && path === '/oidc/jwks') return json(200, { keys: [jwk] }); + if (req.method === 'GET' && path === '/oidc/authorize') { + const q = url.searchParams; + for (const k of new Set(q.keys())) if (q.getAll(k).length !== 1) return json(400, { error: 'invalid_request' }); + if (q.get('client_id') !== CLIENT || q.get('redirect_uri') !== `${ORIGIN}/auth/callback` || q.get('response_type') !== 'code' || + !q.get('scope')?.split(' ').includes('openid') || q.get('code_challenge_method') !== 'S256' || !/^[A-Za-z0-9_-]{43}$/.test(q.get('code_challenge') ?? '') || + !q.get('state') || q.get('state').length > 512 || !q.get('nonce') || q.get('nonce').length > 512 || q.has('login_hint')) return json(400, { error: 'invalid_request' }); + const now = clock(); + for (const [code, grant] of codes) if (grant.expires <= now) codes.delete(code); + if (codes.size >= 100) return json(429, { error: 'too_many_grants' }); + const code = randomBytes(32).toString('base64url'); + codes.set(code, { expires: now + 60000, challenge: q.get('code_challenge'), nonce: q.get('nonce') }); + const callback = new URL(`${ORIGIN}/auth/callback`); callback.searchParams.set('code', code); callback.searchParams.set('state', q.get('state')); + res.writeHead(302, { location: callback.href }); res.end(); return; + } + if (req.method === 'POST' && path === '/oidc/token') { + let body = ''; + req.on('data', c => { body += c; if (body.length > 8192) req.destroy(); }); + req.on('end', () => { + let credentials; + try { credentials = Buffer.from((req.headers.authorization ?? '').replace(/^Basic /, ''), 'base64').toString().split(':').map(decodeURIComponent); } catch { return json(401, { error: 'invalid_client' }); } + if (!req.headers.authorization?.startsWith('Basic ') || credentials.length !== 2 || credentials[0] !== CLIENT || credentials[1] !== secret) return json(401, { error: 'invalid_client' }); + const form = new URLSearchParams(body), code = form.get('code'), grant = codes.get(code); codes.delete(code); + if (!grant || grant.expires <= clock() || form.get('grant_type') !== 'authorization_code' || form.get('redirect_uri') !== `${ORIGIN}/auth/callback` || + createHash('sha256').update(form.get('code_verifier') ?? '').digest('base64url') !== grant.challenge) return json(400, { error: 'invalid_grant' }); + const now = Math.floor(clock() / 1000); + const id_token = jwt({ alg: 'RS256', kid: jwk.kid }, { iss: ISSUER, sub: SUBJECT, aud: CLIENT, name: OWNER, nonce: grant.nonce, iat: now, exp: now + 300 }, { key: privateKey }); + return json(200, { access_token: randomBytes(32).toString('base64url'), token_type: 'Bearer', expires_in: 300, id_token }); + }); return; + } + json(404, { error: 'not_found' }); + }); + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(17443, '127.0.0.1', resolve); }); + return server; +} +export async function main(argv) { + const command = argv.shift(), o = options(argv, command === 'prepare' ? ['--instance','--config','--owner','--runtime','--tls-port'] : ['--runtime']); + if (command === 'prepare') console.log(JSON.stringify(prepare({ instance:o['--instance'], config:o['--config'], owner:o['--owner'], runtime:o['--runtime'], tlsPort:o['--tls-port'] }), null, 2)); + else if (command === 'serve') { const server = await serve(o['--runtime']); for (const signal of ['SIGINT','SIGTERM']) process.once(signal, () => { server.closeAllConnections(); server.close(); }); } + else fail('use prepare or serve'); +} +if (isMain(import.meta.url)) main(process.argv.slice(2)).catch(() => { console.error('isolated identity refused; check named test config, private runtime and free test ports'); process.exitCode = 1; }); diff --git a/packages/cli/live/isolated-identity.test.mjs b/packages/cli/live/isolated-identity.test.mjs new file mode 100644 index 000000000..0647a173b --- /dev/null +++ b/packages/cli/live/isolated-identity.test.mjs @@ -0,0 +1,95 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash, verify, createPublicKey } from 'node:crypto'; +import { request } from 'node:https'; +import { createServer } from 'node:http'; +import { readFileSync, statSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { prepare,serve,SUBJECT } from './isolated-identity.mjs'; +import { fixture } from './test-support.mjs'; +import { readSlot } from '../../webui/src/sso.mjs'; +function setup(t) { + const f=fixture(t),runtime=join(f.root,'identity'); + const output=prepare({instance:'ui797',config:f.config,owner:'ui797-owner',runtime,tlsPort:17443}); + return {...f,runtime,output}; +} +function http(runtime,path,options={}) { + return new Promise((resolve,reject)=>{ + const req=request(`https://127.0.0.1:17443${path}`,{ca:readFileSync(join(runtime,'ca.pem')),...options},res=>{ + let body='';res.on('data',c=>body+=c);res.on('end',()=>resolve({status:res.statusCode,headers:res.headers,body})); + });req.on('error',reject);req.end(options.body); + }); +} +test('prepare writes a valid SSO block, private CA/keys and a real slot, never rewrites config',t=>{ + const f=setup(t),before=readFileSync(f.config,'utf8'); + const p=f.output.console.sso.providers[0]; + assert.deepEqual(p.subjects,[{sub:SUBJECT,name:'ui797-owner'}]); + assert.ok(readSlot(join(f.root,'mosaic-ui797'),p,{repo:process.cwd()}).length>30); + for(const n of ['ca.key','tls.key','signing.pem','identity.json','ca.pem']) assert.equal(statSync(join(f.runtime,n)).mode&0o777,0o600); + assert.equal(readFileSync(f.config,'utf8'),before); + assert.throws(()=>prepare({instance:'ui797',config:f.config,owner:'ui797-owner',runtime:f.runtime,tlsPort:17443})); +}); +test('live/default instances, wrong owner, unclaimed root and unsafe config paths refuse before creating runtime',t=>{ + const f=fixture(t); + const defaults={instance:'ui797',config:f.config,owner:'ui797-owner',runtime:join(f.root,'id'),tlsPort:17443}; + for(const instance of ['dev','prod','production'])assert.throws(()=>prepare({...defaults,instance})); + assert.throws(()=>prepare({...defaults,owner:'jason'})); + assert.throws(()=>prepare({...defaults,config:join(f.root,'mosaic-dev/config.json')})); + assert.throws(()=>prepare({...defaults,tlsPort:7332})); + assert.throws(()=>prepare({...defaults,config:join(process.cwd(),'packages/cli/live/..test/mosaic-ui797/config.json')}),/outside source/); + writeFileSync(join(f.dataRoot,'instance.json'),'{"instanceVersion":1,"name":"dev"}'); + assert.throws(()=>prepare(defaults)); +}); +test('real HTTPS authorization redirects, PKCE and nonce bind a single-use bounded RS256 token',async t=>{ + const f=setup(t),server=await serve(f.runtime);t.after(()=>{server.closeAllConnections();server.close();}); + const discovery=await http(f.runtime,'/oidc/.well-known/openid-configuration');assert.equal(discovery.status,200); + const verifier='a'.repeat(43),challenge=createHash('sha256').update(verifier).digest('base64url'); + const q=new URLSearchParams({client_id:'mosaic-ui797',redirect_uri:'https://127.0.0.1:17443/auth/callback',response_type:'code',scope:'openid',state:'test-state',nonce:'test-nonce',code_challenge:challenge,code_challenge_method:'S256'}); + const auth=await http(f.runtime,'/oidc/authorize?'+q);assert.equal(auth.status,302); + const callback=new URL(auth.headers.location);assert.equal(callback.searchParams.get('state'),'test-state'); + const secret=readFileSync(join(f.root,'mosaic-ui797/secrets/mosaic-stack/ui797-local'),'utf8').trim(); + const tokenOptions={method:'POST',headers:{authorization:'Basic '+Buffer.from('mosaic-ui797:'+secret).toString('base64'),'content-type':'application/x-www-form-urlencoded'},body:new URLSearchParams({grant_type:'authorization_code',code:callback.searchParams.get('code'),redirect_uri:'https://127.0.0.1:17443/auth/callback',code_verifier:verifier}).toString()}; + const token=await http(f.runtime,'/oidc/token',tokenOptions);assert.equal(token.status,200); + const [head,payload,sig]=JSON.parse(token.body).id_token.split('.'),claims=JSON.parse(Buffer.from(payload,'base64url')); + assert.equal(claims.sub,SUBJECT);assert.equal(claims.nonce,'test-nonce');assert.equal(claims.exp-claims.iat,300); + const jwks=JSON.parse((await http(f.runtime,'/oidc/jwks')).body); + assert.ok(verify('sha256',Buffer.from(head+'.'+payload),createPublicKey({key:jwks.keys[0],format:'jwk'}),Buffer.from(sig,'base64url'))); + assert.equal((await http(f.runtime,'/oidc/token',tokenOptions)).status,400); + q.set('redirect_uri','https://mosaic.woltje.com/auth/callback');assert.equal((await http(f.runtime,'/oidc/authorize?'+q)).status,400); + q.set('redirect_uri','https://127.0.0.1:17443/auth/callback');q.set('login_hint','jason');assert.equal((await http(f.runtime,'/oidc/authorize?'+q)).status,400);q.delete('login_hint'); + const second=new URL((await http(f.runtime,'/oidc/authorize?'+q)).headers.location); + const wrong={...tokenOptions,body:new URLSearchParams({grant_type:'authorization_code',code:second.searchParams.get('code'),redirect_uri:'https://127.0.0.1:17443/auth/callback',code_verifier:'wrong'}).toString()}; + assert.equal((await http(f.runtime,'/oidc/token',wrong)).status,400); + assert.equal((await http(f.runtime,'/oidc/jwks',{servername:'127.0.0.1',headers:{host:'mosaic.woltje.com'}})).status,403); +}); +test('fixed TLS forwarder preserves normal Console Host, cookies and origin without injected identity',async t=>{ + const f=setup(t);let headers; + const upstream=createServer((req,res)=>{headers=req.headers;res.end('Console upstream');}); + await new Promise((resolve,reject)=>{upstream.once('error',reject);upstream.listen(17440,'127.0.0.1',resolve);});t.after(()=>upstream.close()); + const server=await serve(f.runtime);t.after(()=>{server.closeAllConnections();server.close();}); + const r=await http(f.runtime,'/test',{headers:{origin:'https://127.0.0.1:17443',cookie:'fixture=value'}}); + assert.equal(r.body,'Console upstream');assert.equal(headers.host,'127.0.0.1:17443');assert.equal(headers.origin,'https://127.0.0.1:17443');assert.equal(headers.cookie,'fixture=value');assert.equal(headers['x-auth-subject'],undefined); +}); +test('production Console CLI discovers this OP through NODE_EXTRA_CA_CERTS with ordinary TLS validation',async t=>{ + const f=setup(t),config=JSON.parse(readFileSync(f.config));config.console=f.output.console;writeFileSync(f.config,JSON.stringify(config)); + const server=await serve(f.runtime);t.after(()=>{server.closeAllConnections();server.close();}); + const child=spawn(process.execPath,['packages/webui/src/cli.mjs','serve','--port','17440','--board','http://127.0.0.1:17439','--sso'],{env:{...process.env,MOSAIC_CONFIG:f.config,NODE_EXTRA_CA_CERTS:join(f.runtime,'ca.pem')},stdio:['ignore','pipe','pipe']}); + t.after(async()=>{if(child.exitCode===null){child.kill('SIGTERM');await once(child,'exit');}}); + let output='';child.stdout.on('data',c=>output+=c);child.stderr.on('data',c=>output+=c); + await new Promise((resolve,reject)=>{const timer=setTimeout(()=>reject(new Error('Console startup: '+output)),15000);child.stdout.on('data',()=>{if(output.includes('http://127.0.0.1:17440')){clearTimeout(timer);resolve();}});child.once('exit',()=>{clearTimeout(timer);reject(new Error('Console refused: '+output));});}); + const health=JSON.parse((await http(f.runtime,'/healthz')).body);assert.equal(health.sso['ui797-local'],'ready'); +}); +test('authorization codes expire, and tampering with the single subject refuses service',async t=>{ + const f=setup(t);let now=Date.now(); + const server=await serve(f.runtime,{clock:()=>now});t.after(()=>{server.closeAllConnections();server.close();}); + const verifier='v'.repeat(43),challenge=createHash('sha256').update(verifier).digest('base64url'); + const q=new URLSearchParams({client_id:'mosaic-ui797',redirect_uri:'https://127.0.0.1:17443/auth/callback',response_type:'code',scope:'openid',state:'state',nonce:'nonce',code_challenge:challenge,code_challenge_method:'S256'}); + const code=new URL((await http(f.runtime,'/oidc/authorize?'+q)).headers.location).searchParams.get('code');now+=60001; + const secret=readFileSync(join(f.root,'mosaic-ui797/secrets/mosaic-stack/ui797-local'),'utf8').trim(); + const response=await http(f.runtime,'/oidc/token',{method:'POST',headers:{authorization:'Basic '+Buffer.from('mosaic-ui797:'+secret).toString('base64')},body:new URLSearchParams({grant_type:'authorization_code',code,redirect_uri:'https://127.0.0.1:17443/auth/callback',code_verifier:verifier}).toString()}); + assert.equal(response.status,400); + const stateFile=join(f.runtime,'identity.json'),state=JSON.parse(readFileSync(stateFile));state.console.sso.providers[0].subjects=[{sub:'jason',name:'jason'}];writeFileSync(stateFile,JSON.stringify(state)); + await assert.rejects(serve(f.runtime),/metadata refused/); +}); diff --git a/packages/cli/live/support-common.mjs b/packages/cli/live/support-common.mjs new file mode 100644 index 000000000..b741d4343 --- /dev/null +++ b/packages/cli/live/support-common.mjs @@ -0,0 +1,71 @@ +// Owner-run, local test support. No product capability or processing-state writes. +import { constants, closeSync, fsyncSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'; +import { dirname, isAbsolute, join, resolve, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { homedir } from 'node:os'; +export const ROOT = fileURLToPath(new URL('../../../', import.meta.url)); +export const fail = message => { throw new Error(message); }; +export const inside = (parent, path) => { const r = relative(parent, path); return !r || (r !== '..' && !r.startsWith('../') && !isAbsolute(r)); }; +export function plainPath(path) { + if (!isAbsolute(path ?? '') || resolve(path) !== path) fail('canonical absolute path required'); + let cursor = path; + while (cursor !== dirname(cursor)) { + try { if (lstatSync(cursor).isSymbolicLink()) fail('symbolic link refused'); } + catch (e) { if (e.code !== 'ENOENT') throw e; } + cursor = dirname(cursor); + } + return path; +} +export function privateDir(path, create = false) { + plainPath(path); + if (create) mkdirSync(path, { mode: 0o700 }); + const s = lstatSync(path); + if (!s.isDirectory() || s.uid !== process.getuid() || (s.mode & 0o777) !== 0o700) fail('private owner directory required'); + return path; +} +export function readPrivate(file) { + plainPath(file); + const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + const s = fstatSync(fd); + if (!s.isFile() || s.uid !== process.getuid() || (s.mode & 0o777) !== 0o600 || s.size > 16 * 1024 * 1024) fail('private bounded owner file required'); + return readFileSync(fd, 'utf8'); + } finally { closeSync(fd); } +} +export function exclusive(file, value) { + plainPath(file); + const fd = openSync(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); + try { writeFileSync(fd, value); fsyncSync(fd); } finally { closeSync(fd); } +} +export function options(argv, allowed) { + const out = {}; + while (argv.length) { + const key = argv.shift(); + if (!allowed.includes(key) || Object.hasOwn(out, key) || !argv.length) fail('unknown, duplicate or missing option'); + out[key] = argv.shift(); + } + return out; +} +export function testInstance(instance, config) { + if (!/^[a-z][a-z0-9]{1,15}$/.test(instance ?? '') || ['dev','prod','production'].includes(instance)) fail('explicit named test instance required'); + plainPath(config); + const copies = join(process.env.XDG_DATA_HOME || join(homedir(), '.local/share'), 'mosaic-stack/copies'); + if (inside(ROOT, config) || inside(copies, config)) fail('test config and secret slots must be outside source and stack copies'); + if (config.split('/').includes('mosaic-dev') || config.split('/').includes('.mosaic-dev') || dirname(config).split('/').at(-1) !== `mosaic-${instance}`) fail('matching named test config required; mosaic-dev refused'); + if (process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0') fail('TLS verification must remain enabled'); + const run = spawnSync(process.execPath, [join(ROOT, 'scripts/mosaic-config.mjs'), 'validate'], { env: { ...process.env, MOSAIC_CONFIG: config }, encoding: 'utf8' }); + if (run.status !== 0) fail('test system configuration validation refused'); + const system = JSON.parse(run.stdout); + plainPath(system.dataRoot); + if (inside(ROOT, system.dataRoot) || inside(copies, system.dataRoot)) fail('test data root must be outside source and stack copies'); + if (system.environment !== 'development' || system.dataRoot.split('/').some(p => ['.mosaic-dev','mosaic-dev'].includes(p))) fail('isolated development data root required'); + const marker = JSON.parse(readFileSync(join(system.dataRoot, 'instance.json'), 'utf8')); + if (marker.instanceVersion !== 1 || marker.name !== instance) fail('test instance marker mismatch'); + return { system, config: realpathSync(config) }; +} +export function outsideRuntime(path, system, config) { + plainPath(path); + if (inside(ROOT, path) || inside(system.dataRoot, path) || inside(dirname(config), path)) fail('runtime must be outside repository, config and model data'); +} +export function isMain(meta) { return process.argv[1] && fileURLToPath(meta) === resolve(process.argv[1]); } diff --git a/packages/cli/live/test-support.mjs b/packages/cli/live/test-support.mjs new file mode 100644 index 000000000..5b2e20b2b --- /dev/null +++ b/packages/cli/live/test-support.mjs @@ -0,0 +1,15 @@ +// Focused test fixtures only; runtime CLIs never import this file. +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +export function fixture(t) { + if (!process.env.TMPDIR?.startsWith('/mnt/storage/scratch/tmp/') || process.env.TMPDIR.includes('_')) throw new Error('set TMPDIR with mktemp -d /mnt/storage/scratch/tmp/XXXXXX'); + const root=mkdtempSync(join(tmpdir(),'ui797-')); + t.after(()=>rmSync(root,{recursive:true,force:true})); + const configDir=join(root,'mosaic-ui797'),dataRoot=join(root,'data'); + mkdirSync(configDir,{mode:0o700});mkdirSync(dataRoot,{mode:0o700}); + const config=join(configDir,'config.json'); + writeFileSync(config,JSON.stringify({configVersion:1,environment:'development',dataRoot,execution:{backend:'docker',provider:'zai',model:'glm-5.3-flash'}})); + writeFileSync(join(dataRoot,'instance.json'),JSON.stringify({instanceVersion:1,name:'ui797'})); + return {root,config,dataRoot}; +} diff --git a/packages/cli/live/ui-browser.mjs b/packages/cli/live/ui-browser.mjs new file mode 100644 index 000000000..d3d421d17 --- /dev/null +++ b/packages/cli/live/ui-browser.mjs @@ -0,0 +1,279 @@ +#!/usr/bin/env node +// Persistent CDP pipe client, based on webui/tests/browser.mjs. No injected app script. +import { spawn, fork, spawnSync } from 'node:child_process'; +import { createServer, createConnection } from 'node:net'; +import { once } from 'node:events'; +import { createHash, X509Certificate } from 'node:crypto'; +import { appendFileSync, chmodSync, existsSync, readdirSync, readFileSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { homedir } from 'node:os'; +import { exclusive, fail, isMain, options, plainPath, privateDir, readPrivate } from './support-common.mjs'; +const HERE = fileURLToPath(import.meta.url); +const INTERACTIVE = new Set(['button','link','textbox','searchbox','combobox','checkbox','radio','switch','tab','menuitem','option','spinbutton','slider']); +const delay = ms => new Promise(r => setTimeout(r, ms)); +export function testURL(value, origin = null) { + const u = new URL(value, origin ?? undefined); + if (u.hostname !== '127.0.0.1' || !['17440','17443'].includes(u.port) || !['http:','https:'].includes(u.protocol) || u.username || u.password || + (u.port === '17443' && u.protocol !== 'https:') || (u.port === '17440' && u.protocol !== 'http:')) fail('only loopback test Console ports 17440/17443 allowed'); + if (origin && u.origin !== origin) fail('navigation must stay on the selected test origin'); + return u; +} +async function chromium(evidence, ca) { + const cert = new X509Certificate(readPrivate(ca)); + if (!cert.ca || Date.parse(cert.validTo) <= Date.now() || Date.parse(cert.validFrom) > Date.now()) fail('valid private test CA required'); + const xdg = join(evidence, 'xdg'); privateDir(xdg, true); + const pki = join(xdg, 'pki'); privateDir(pki, true); + const nss = join(pki, 'nssdb'); privateDir(nss, true); + for (const args of [['-N','--empty-password','-d',`sql:${nss}`], ['-A','-d',`sql:${nss}`,'-n','ui797-private-test-ca','-t','C,,','-i',ca]]) { + const r = spawnSync('certutil', args, { encoding:'utf8' }); + if (r.status !== 0) fail('private Chromium CA trust setup failed (certutil required)'); + } + for (const name of readdirSync(nss)) chmodSync(join(nss,name),0o600); + const profile = join(evidence, 'profile'); privateDir(profile, true); + // NSS prefers ~/.pki/nssdb when it exists, even with XDG_DATA_HOME set. Overlay it + // in a private mount namespace, never mutate the owner's trust store or change HOME. + // On newer clean hosts, the process-local XDG database alone is sufficient. + const legacyNss = join(homedir(), '.pki/nssdb'); + const legacy = existsSync(legacyNss); + if (legacy) plainPath(legacyNss); + const executable = process.env.CHROMIUM || '/usr/bin/chromium'; + const chromeArgs = ['--headless','--no-first-run','--no-default-browser-check','--disable-dev-shm-usage', + '--disable-background-networking','--disable-component-update','--disable-sync','--no-proxy-server','--remote-debugging-pipe', + `--user-data-dir=${profile}`]; + const child = spawn(legacy ? '/usr/bin/bwrap' : executable, legacy ? ['--die-with-parent','--bind','/','/','--dev-bind','/dev','/dev','--proc','/proc','--bind',nss,legacyNss,'--',executable,...chromeArgs] : chromeArgs, + { env:{...process.env,XDG_DATA_HOME:xdg}, stdio: ['ignore','ignore','ignore','pipe','pipe'] }); + let id = 0, buffer = ''; + const pending = new Map(), events = new Map(); + const rejectAll = () => { for (const p of pending.values()) { clearTimeout(p.timer); p.reject(new Error('Chromium exited')); } pending.clear(); }; + child.once('error', rejectAll); child.once('exit', rejectAll); + child.stdio[3].on('error', rejectAll); child.stdio[4].on('error', rejectAll); + child.stdio[4].on('data', chunk => { + buffer += chunk.toString(); let end; + while ((end = buffer.indexOf('\0')) !== -1) { + const msg = JSON.parse(buffer.slice(0,end)); buffer = buffer.slice(end+1); + if (msg.method) for (const f of events.get(msg.method) ?? []) f(msg.params, msg.sessionId); + const p = pending.get(msg.id); + if (p) { pending.delete(msg.id); clearTimeout(p.timer); msg.error ? p.reject(new Error('CDP refused ' + p.method)) : p.resolve(msg.result); } + } + }); + const send = (method, params = {}, sessionId) => new Promise((resolve, reject) => { + const key = ++id, timer = setTimeout(() => { pending.delete(key); reject(new Error('CDP timeout: ' + method)); }, 15000); + pending.set(key, { resolve, reject, timer, method }); + child.stdio[3].write(JSON.stringify({ id:key, method, params, ...(sessionId ? { sessionId } : {}) }) + '\0'); + }); + const on = (method, f) => { if (!events.has(method)) events.set(method,new Set()); events.get(method).add(f); }; + const { targetId } = await send('Target.createTarget', { url:'about:blank' }); + const { sessionId } = await send('Target.attachToTarget', { targetId, flatten:true }); + const call = (method, params) => send(method, params, sessionId); + return { call, send, on, child, async close() { + await send('Browser.close').catch(() => {}); + if (child.exitCode === null) await once(child, 'exit'); + } }; +} +export async function daemon(evidence) { + privateDir(evidence); + const state = JSON.parse(readPrivate(join(evidence, 'browser.json'))), origin = testURL(state.origin).origin; + const b = await chromium(evidence, state.ca); + try { + let sequence = 0, generation = 0, refs = new Map(), blocked = false, detail = {}; + const completed = new Map(), pendingDownloads = new Set(); + const downloadsDir = join(evidence,'downloads'); privateDir(downloadsDir, true); + b.on('Browser.downloadWillBegin', e => { pendingDownloads.add(e.guid); }); + b.on('Browser.downloadProgress', e => { if (e.state === 'completed') { completed.set(e.guid, true); pendingDownloads.delete(e.guid); } }); + b.on('Page.frameNavigated', e => { if (!e.frame.parentId) { generation++; refs.clear(); } }); + const refused = async id => { + blocked = true; + await b.call('Fetch.failRequest', { requestId:id, errorReason:'BlockedByClient' }).catch(() => {}); + }; + // Includes subresources, redirects and popups. No external live Console can be contacted. + b.on('Fetch.requestPaused', e => { + let allowed = false; + try { allowed = testURL(e.request.url).origin === origin; } catch {} + (allowed ? b.call('Fetch.continueRequest', { requestId:e.requestId }) : refused(e.requestId)).catch(() => { blocked = true; }); + }); + // Deny new targets (including window.open) before they are allowed to run. + b.on('Target.attachedToTarget', e => { + if (e.waitingForDebugger) b.send('Target.closeTarget', { targetId:e.targetInfo.targetId }).catch(() => {}); + }); + await b.send('Target.setAutoAttach', { autoAttach:true, waitForDebuggerOnStart:true, flatten:true }); + await b.call('Page.enable'); await b.call('Accessibility.enable'); await b.call('DOM.enable'); + await b.call('Network.enable'); await b.call('Network.setBlockedURLs', { urls:['ws://*','wss://*'] }); + await b.call('Fetch.enable', { patterns:[{ urlPattern:'*', requestStage:'Request' }] }); + await b.send('Browser.setDownloadBehavior', { behavior:'allowAndName', downloadPath:downloadsDir, eventsEnabled:true }); + await b.call('Emulation.setDeviceMetricsOverride', { width:1280, height:1000, deviceScaleFactor:1, mobile:false }); + const shot = async name => { const { data } = await b.call('Page.captureScreenshot', { format:'png' }); exclusive(join(evidence,name), Buffer.from(data,'base64')); return name; }; + async function snapshot() { + const { nodes } = await b.call('Accessibility.getFullAXTree'); + const text = [], elements = []; + const next = new Map(); + for (const n of nodes) { + if (n.ignored) continue; + const role = n.role?.value, name = n.name?.value ?? ''; + if (role === 'StaticText' && name) text.push(name); + if (INTERACTIVE.has(role) && n.backendDOMNodeId) { + const ref = `r${generation}-${n.backendDOMNodeId}`; + next.set(ref,n.backendDOMNodeId); + // Values (including secret input) never enter snapshots or receipts. + elements.push({ ref, role, name, disabled:n.properties?.some(p => p.name === 'disabled' && p.value?.value === true) ?? false }); + } + } + refs = next; + return { text:text.join('\n'), elements }; + } + const currentURL = async () => { + const { frameTree } = await b.call('Page.getFrameTree'); + if (frameTree.frame.url !== 'about:blank') testURL(frameTree.frame.url, origin); + }; + async function focus(ref) { + const backendNodeId = refs.get(ref); if (!backendNodeId) fail('unknown or stale ref; snapshot again'); + await b.call('DOM.scrollIntoViewIfNeeded', { backendNodeId }); + const { quads } = await b.call('DOM.getContentQuads', { backendNodeId }); + const q = quads?.[0]; if (!q || Math.abs((q[2]-q[0])*(q[5]-q[1])) < 1) fail('element has no visible target'); + const x = (q[0]+q[2]+q[4]+q[6])/4, y = (q[1]+q[3]+q[5]+q[7])/4; + await b.call('Input.dispatchMouseEvent', { type:'mouseMoved',x,y }); + await b.call('Input.dispatchMouseEvent', { type:'mousePressed',x,y,button:'left',clickCount:1 }); + await b.call('Input.dispatchMouseEvent', { type:'mouseReleased',x,y,button:'left',clickCount:1 }); + } + async function press(key) { + const parts = key.split('+'), base = parts.pop(); let modifiers = 0; + for (const part of parts) { + const mask = { Alt:1, Ctrl:2, Control:2, Meta:4, Shift:8 }[part]; + if (!mask || (modifiers & mask)) fail('unsupported key modifier'); + modifiers |= mask; + } + let spec = { Enter:['Enter',13,'\r'], Tab:['Tab',9,''], Escape:['Escape',27,''], Backspace:['Backspace',8,''], Delete:['Delete',46,''], Home:['Home',36,''], End:['End',35,''], PageUp:['PageUp',33,''], PageDown:['PageDown',34,''], ArrowDown:['ArrowDown',40,''], ArrowUp:['ArrowUp',38,''], ArrowLeft:['ArrowLeft',37,''], ArrowRight:['ArrowRight',39,''], Space:['Space',32,' '] }[base]; + if (!spec && /^[a-zA-Z0-9]$/.test(base)) spec = [/[a-zA-Z]/.test(base) ? `Key${base.toUpperCase()}` : `Digit${base}`,base.toUpperCase().charCodeAt(0),base]; + if (!spec) fail('unsupported key'); + detail.key=key; + const event = { key:base === 'Space' ? ' ' : base,code:spec[0],windowsVirtualKeyCode:spec[1],modifiers }; + await b.call('Input.dispatchKeyEvent', { ...event,type:'keyDown',text:modifiers ? '' : spec[2] }); + await b.call('Input.dispatchKeyEvent', { ...event,type:'keyUp' }); + } + async function navigate(path) { + const url = testURL(path,origin); + // Authentication query strings are never accepted as CLI inputs or retained. + if (url.pathname === '/auth/callback' || url.pathname.startsWith('/oidc/')) fail('navigate via the Console sign-in control'); + const r = await b.call('Page.navigate', { url:url.href }); + if (r.errorText) fail('navigation failed'); + for (let n=0;n<100;n++) { + const { root } = await b.call('DOM.getDocument'); + if (root.childNodeCount > 0) { await delay(100); return; } + await delay(50); + } + fail('navigation timed out'); + } + async function perform(command,args) { + switch (command) { + case 'start': await navigate('/'); return snapshot(); + case 'goto': if (args.length !== 1) fail('goto PATH'); await navigate(args[0]); return snapshot(); + case 'snapshot': if (args.length) fail('snapshot takes no arguments'); return snapshot(); + case 'click': { + let ref; + if (args.length === 2 && args[0] === '--text') { + const s = await snapshot(), found = s.elements.filter(e => e.name === args[1] && !e.disabled); + if (found.length !== 1) fail('text must name exactly one enabled interactive element'); ref = found[0].ref; + } else if (args.length === 1) ref = args[0]; else fail('click REF or --text TEXT'); + if (/^r[0-9]+-[0-9]+$/.test(ref)) detail.ref = ref; await focus(ref); await delay(150); return snapshot(); + } + case 'type': if (args.length !== 2) fail('type REF TEXT'); if (/^r[0-9]+-[0-9]+$/.test(args[0])) detail.ref=args[0]; await focus(args[0]); await b.call('Input.insertText', { text:args[1] }); return snapshot(); + case 'press': if (args.length !== 1) fail('press KEY'); await press(args[0]); await delay(100); return snapshot(); + case 'wait': { + const o = options([...args],['--text','--timeout']); const timeout = Number(o['--timeout'] ?? 10); + if (!o['--text'] || !Number.isFinite(timeout) || timeout <= 0 || timeout > 120) fail('wait --text TEXT --timeout 0..120 seconds'); + const end = Date.now()+timeout*1000; + do { const s = await snapshot(); if (s.text.includes(o['--text']) || s.elements.some(e => e.name.includes(o['--text']))) return s; await delay(100); } while (Date.now() { const file = join(downloadsDir,name), bytes = readFileSync(file); chmodSync(file,0o600); return { file, bytes:bytes.length, sha256:createHash('sha256').update(bytes).digest('hex') }; }); + } + case 'stop': return { stopped:true }; + default: fail('unknown browser command'); + } + } + async function action(command,args) { + detail = {}; + const n = String(++sequence).padStart(5,'0'), before = await shot(`${n}-before.png`); + let result, error, after; + try { if (command !== 'stop') await currentURL(); result = await perform(command,args); if (command !== 'stop') { await currentURL(); if (blocked) fail('non-test network request blocked; stop session'); } } + catch (e) { error = e.message; } + finally { + after = await shot(`${n}-after.png`).catch(() => null); + // No text input, URLs, auth codes, tokens, cookie/header/body values in the journal. + appendFileSync(join(evidence,'actions.jsonl'), JSON.stringify({ sequence,at:new Date().toISOString(),command,...detail,before,after,ok:!error,error:error ?? null })+'\n', { mode:0o600 }); + } + if (error) throw new Error(error); + return result; + } + let tail = Promise.resolve(), stopping = false; + const socket = join(evidence,'browser.sock'); + if (Buffer.byteLength(socket)>100) fail('evidence path too long for Unix socket'); + const server = createServer(peer => { + peer.on('error', () => {}); + peer.setTimeout(150000, () => peer.destroy()); let data = ''; + peer.on('data', chunk => { + data += chunk; if (data.length > 65536) return peer.destroy(); + if (!data.includes('\n')) return; + peer.removeAllListeners('data'); + tail = tail.then(async () => { + let request; + try { + request = JSON.parse(data); if (!Array.isArray(request.args) || request.args.some(a => typeof a !== 'string') || stopping) fail('bad browser command'); + const value = await action(request.command,request.args); + if (request.command === 'stop') { stopping = true; server.close(); await b.close(); if (existsSync(socket)) unlinkSync(socket); } + peer.end(JSON.stringify({ ok:true,value })+'\n'); + } catch (e) { peer.end(JSON.stringify({ ok:false,error:e.message })+'\n'); } + }); + }); + }); + await new Promise((resolve,reject) => { server.once('error',reject); server.listen(socket,resolve); }); chmodSync(socket,0o600); + const cleanup = async () => { server.close(); await b.close(); if (existsSync(socket)) unlinkSync(socket); }; + for (const signal of ['SIGTERM','SIGINT']) process.once(signal, () => cleanup().finally(() => process.exit(0))); + return { server, cleanup }; + } catch (e) { await b.close().catch(() => {}); throw e; } +} +export function callBrowser(evidence, command, args=[]) { + privateDir(evidence); readPrivate(join(evidence,'browser.json')); + return new Promise((resolve,reject) => { + const peer = createConnection(join(evidence,'browser.sock')); let data = ''; + peer.on('error', () => {}); + peer.setTimeout(150000, () => peer.destroy(new Error('browser action timed out; inspect receipts before retry'))); + peer.on('error',reject); peer.on('connect', () => peer.write(JSON.stringify({ command,args })+'\n')); + peer.on('data',chunk => { data += chunk; }); peer.on('end', () => { try { const r = JSON.parse(data); r.ok ? resolve(r.value) : reject(new Error(r.error)); } catch(e) { reject(e); } }); + }); +} +export async function start({ evidence, origin, ca }) { + if (testURL(origin).origin !== origin) fail('origin must have no path, query or fragment'); + plainPath(ca); readPrivate(ca); plainPath(evidence); privateDir(evidence,true); + exclusive(join(evidence,'browser.json'),JSON.stringify({ version:1,origin,ca })+'\n'); + const child = fork(HERE,['--daemon',evidence],{ detached:true,stdio:['ignore','ignore','ignore','ipc'] }); + try { + await new Promise((resolve,reject) => { + const timer = setTimeout(() => { child.kill('SIGTERM'); reject(new Error('browser daemon startup timed out')); },20000); + child.once('error',reject); child.once('exit', () => { clearTimeout(timer); reject(new Error('browser daemon failed to start')); }); + child.once('message',m => { clearTimeout(timer); m.ok ? resolve() : reject(new Error(m.error)); }); + }); + } catch (e) { child.kill('SIGTERM'); if (child.connected) child.disconnect(); child.unref(); throw e; } + child.disconnect(); child.unref(); + return callBrowser(evidence,'start'); +} +export async function main(argv, env=process.env) { + const command = argv.shift(); + if (command === '--daemon') { await daemon(argv[0]); process.send?.({ok:true}); return; } + if (command === 'start') { + const o = options(argv,['--evidence','--origin','--ca']); return start({evidence:o['--evidence'],origin:o['--origin'],ca:o['--ca']}); + } + let evidence = env.UI797_EVIDENCE; + const index = argv.indexOf('--evidence'); + if (index >= 0) { evidence = argv[index+1]; argv.splice(index,2); } + return callBrowser(evidence,command,argv); +} +if (isMain(import.meta.url)) main(process.argv.slice(2)).then(r => { if (r !== undefined) console.log(JSON.stringify(r,null,2)); }).catch(e => { if (process.send) process.send({ok:false,error:'browser daemon startup refused'}); else console.error(e.message); process.exitCode=1; }); diff --git a/packages/cli/live/ui-browser.test.mjs b/packages/cli/live/ui-browser.test.mjs new file mode 100644 index 000000000..cd37efecf --- /dev/null +++ b/packages/cli/live/ui-browser.test.mjs @@ -0,0 +1,91 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { readFileSync, readdirSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { spawn } from 'node:child_process'; +import { start,callBrowser,testURL } from './ui-browser.mjs'; +import { prepare,serve } from './isolated-identity.mjs'; +import { fixture } from './test-support.mjs'; +const pause=ms=>new Promise(r=>setTimeout(r,ms)); +test('browser URL guard refuses live, remote, credentials, non-test ports and origin changes',()=>{ + for(const u of ['http://127.0.0.1:7332','https://mosaic.woltje.com','http://10.1.1.53:17440','http://localhost:17440','http://u:p@127.0.0.1:17440','https://127.0.0.1:17440','http://127.0.0.1:17443'])assert.throws(()=>testURL(u)); + assert.equal(testURL('/chat','http://127.0.0.1:17440').pathname,'/chat'); + assert.throws(()=>testURL('https://127.0.0.1:17443','http://127.0.0.1:17440')); +}); +async function setup(t,html) { + const f=fixture(t),identity=join(f.root,'id'); + prepare({instance:'ui797',config:f.config,owner:'ui797-owner',runtime:identity,tlsPort:17443}); + const hits=[]; + const server=createServer((req,res)=>{ + hits.push(req.url); + if(req.url==='/download'){res.writeHead(200,{'content-disposition':'attachment; filename="product.html"','content-type':'text/html'});res.end('

offline artifact

');return;} + if(req.url==='/redirect'){res.writeHead(302,{location:'http://127.0.0.1:7332/live'});res.end();return;} + res.setHeader('content-type','text/html');res.end(html); + }); + await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(17440,'127.0.0.1',resolve);});t.after(()=>server.close()); + const evidence=join(f.root,'browser'); + t.after(async()=>{if(existsSync(join(evidence,'browser.sock')))await callBrowser(evidence,'stop').catch(()=>{});}); + return {...f,identity,evidence,hits}; +} +test('real Chromium keeps a session across CLI processes; mouse/keyboard refs, screenshots and actual downloads',async t=>{ + const f=await setup(t,`fixture

Browser fixture

Download reviewed HTML`); + await start({evidence:f.evidence,origin:'http://127.0.0.1:17440',ca:join(f.identity,'ca.pem')}); + const snapshot=await callBrowser(f.evidence,'snapshot');assert.match(snapshot.text,/Browser fixture/); + const input=snapshot.elements.find(e=>e.name==='Request');assert.ok(input); + const again=await callBrowser(f.evidence,'snapshot');assert.equal(again.elements.find(e=>e.name==='Request').ref,input.ref); + await callBrowser(f.evidence,'type',[input.ref,'discard this']); + await callBrowser(f.evidence,'press',['Ctrl+A']); + await callBrowser(f.evidence,'press',['Backspace']); + await callBrowser(f.evidence,'type',[input.ref,'Pomodoro from keyboard']); + await callBrowser(f.evidence,'click',['--text','Send']); + await callBrowser(f.evidence,'wait',['--text','Pomodoro from keyboard','--timeout','2']); + const cli=spawn(process.execPath,['packages/cli/live/ui-browser.mjs','snapshot','--evidence',f.evidence],{stdio:['ignore','pipe','pipe']});let out='',err='';cli.stdout.on('data',c=>out+=c);cli.stderr.on('data',c=>err+=c); + const code=await new Promise(r=>cli.once('exit',r));assert.equal(code,0,err);assert.match(out,/Pomodoro from keyboard/); + await callBrowser(f.evidence,'screenshot',['fixture']); + assert.ok(readFileSync(join(f.evidence,'fixture.png')).length>1000); + await callBrowser(f.evidence,'click',['--text','Download reviewed HTML']); + await pause(300); + const downloads=await callBrowser(f.evidence,'downloads');assert.equal(downloads.length,1);assert.equal(downloads[0].sha256.length,64);assert.equal(readFileSync(downloads[0].file,'utf8'),'

offline artifact

'); + await assert.rejects(callBrowser(f.evidence,'goto',['http://127.0.0.1:7332']),/only loopback/); + await assert.rejects(callBrowser(f.evidence,'click',['bad-ref']),/stale ref/); + await assert.rejects(callBrowser(f.evidence,'wait',['--text','missing','--timeout','0.2']),/timed out/); + await callBrowser(f.evidence,'press',['Tab']);await callBrowser(f.evidence,'stop'); + const receipts=readFileSync(join(f.evidence,'actions.jsonl'),'utf8').trim().split('\n').map(JSON.parse); + assert.equal(receipts.length,18);assert.ok(receipts.some(r=>r.ok===false)); + for(const r of receipts){assert.ok(existsSync(join(f.evidence,r.before)));assert.ok(existsSync(join(f.evidence,r.after)));} + assert.equal(receipts.map(r=>r.sequence).join(','),Array.from({length:18},(_,i)=>i+1).join(',')); + assert.ok(!JSON.stringify(receipts).includes('Pomodoro from keyboard')); + assert.ok(readdirSync(join(f.evidence,'profile')).length>0); +}); +test('redirects to the live port are blocked before a request reaches it',async t=>{ + const f=await setup(t,'

safe

Escape'); + await start({evidence:f.evidence,origin:'http://127.0.0.1:17440',ca:join(f.identity,'ca.pem')}); + await assert.rejects(callBrowser(f.evidence,'click',['--text','Escape']),/blocked|only loopback/); + assert.ok(f.hits.includes('/redirect')); + assert.ok(readFileSync(join(f.evidence,'actions.jsonl'),'utf8').includes('false')); +}); +test('private CA browser performs the actual authorization redirect into production Console SSO',async t=>{ + const {writeFileSync}=await import('node:fs'); + const f=fixture(t),identity=join(f.root,'id'),evidence=join(f.root,'browser'); + const output=prepare({instance:'ui797',config:f.config,owner:'ui797-owner',runtime:identity,tlsPort:17443}); + const config=JSON.parse(readFileSync(f.config));config.console=output.console;writeFileSync(f.config,JSON.stringify(config)); + const idp=await serve(identity);t.after(()=>{idp.closeAllConnections();idp.close();}); + const child=spawn(process.execPath,['packages/webui/src/cli.mjs','serve','--port','17440','--board','http://127.0.0.1:17439','--sso'],{env:{...process.env,MOSAIC_CONFIG:f.config,NODE_EXTRA_CA_CERTS:join(identity,'ca.pem')},stdio:['ignore','pipe','pipe']});let text='';child.stdout.on('data',c=>text+=c);child.stderr.on('data',c=>text+=c); + t.after(async()=>{if(existsSync(join(evidence,'browser.sock')))await callBrowser(evidence,'stop').catch(()=>{});if(child.exitCode===null){child.kill('SIGTERM');await new Promise(r=>child.once('exit',r));}}); + await new Promise((resolve,reject)=>{const timer=setTimeout(()=>reject(new Error(text)),15000);child.stdout.on('data',()=>{if(text.includes('http://127.0.0.1:17440')){clearTimeout(timer);resolve();}});child.once('exit',()=>{clearTimeout(timer);reject(new Error(text));});}); + const initial=await start({evidence,origin:'https://127.0.0.1:17443',ca:join(identity,'ca.pem')}); + // The sole provider signs in immediately through redirects during start. + const signedIn=await callBrowser(evidence,'wait',['--text','Sign out','--timeout','10']); + assert.ok(signedIn.elements.some(e=>e.name==='Sign out')); + assert.ok(!text.includes('sso refused')); + assert.ok(!readFileSync(join(evidence,'actions.jsonl'),'utf8').includes('code=')); +}); +test('a different private CA is refused by Chromium with TLS verification enabled',async t=>{ + const f=fixture(t),g=fixture(t),id=join(f.root,'id'),other=join(g.root,'id'),evidence=join(f.root,'browser'); + for(const [config,runtime] of [[f.config,id],[g.config,other]])prepare({instance:'ui797',config,owner:'ui797-owner',runtime,tlsPort:17443}); + const server=await serve(other);t.after(()=>{server.closeAllConnections();server.close();}); + t.after(async()=>{if(existsSync(join(evidence,'browser.sock')))await callBrowser(evidence,'stop');}); + await assert.rejects(start({evidence,origin:'https://127.0.0.1:17443',ca:join(id,'ca.pem')}),/navigation failed/); + assert.ok(readFileSync(join(evidence,'actions.jsonl'),'utf8').includes('false')); +}); From 59801efe4dffd1c149cc7e60e8feedda04ff7f90 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 5 Nov 2026 21:12:47 -0600 Subject: [PATCH 07/32] feat(webui): add safe reviewed file text view (#797) --- packages/webui/src/public/files.css | 4 + packages/webui/src/public/files.js | 78 +++++++++++++++++++ packages/webui/src/request-http.mjs | 8 +- packages/webui/src/serve.mjs | 2 + packages/webui/tests/artifact-http.test.mjs | 28 +++++++ packages/webui/tests/helpers/w15-files.mjs | 27 +++++++ .../webui/tests/w15-files-browser.test.mjs | 35 +++++++++ 7 files changed, 179 insertions(+), 3 deletions(-) create mode 100644 packages/webui/src/public/files.css create mode 100644 packages/webui/src/public/files.js create mode 100644 packages/webui/tests/helpers/w15-files.mjs create mode 100644 packages/webui/tests/w15-files-browser.test.mjs diff --git a/packages/webui/src/public/files.css b/packages/webui/src/public/files.css new file mode 100644 index 000000000..88d4e98f6 --- /dev/null +++ b/packages/webui/src/public/files.css @@ -0,0 +1,4 @@ +.mosaic-files { min-width: 0; } +.mosaic-files-request { border: 1px solid var(--border); border-radius: 6px; padding: 1rem; margin-block: 1rem; overflow-wrap: anywhere; } +.mosaic-files-actions { display: flex; flex-wrap: wrap; align-items: center; gap: .75rem; } +.mosaic-files-actions a { display: inline-flex; align-items: center; min-height: 44px; } diff --git a/packages/webui/src/public/files.js b/packages/webui/src/public/files.js new file mode 100644 index 000000000..5021dcb9d --- /dev/null +++ b/packages/webui/src/public/files.js @@ -0,0 +1,78 @@ +/* The caller supplies request IDs, never paths or authority. Every mount reads the owner API. */ +(() => { + 'use strict'; + const id = value => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/.test(value); + const generations = new WeakMap(); + let signedOut = false; + document.addEventListener('mosaic-signed-out', () => { signedOut = true; document.querySelectorAll('.mosaic-files').forEach(el => el.replaceChildren()); }); + function element(tag, content, className) { + const node = document.createElement(tag); + if (content !== undefined) node.textContent = content; + if (className) node.className = className; + return node; + } + async function read(url) { + try { + const response = await fetch(url, { cache: 'no-store', redirect: 'manual' }); + if (response.status === 401 || response.type === 'opaqueredirect') { + if (!signedOut) document.dispatchEvent(new CustomEvent('mosaic-signed-out', { detail: { signIn: true } })); + return null; + } + return response.ok ? await response.json() : null; + } catch { return null; } + } + function link(label, href, download) { + const a = element('a', label); + a.href = href; + if (download) a.setAttribute('download', ''); + else { a.target = '_blank'; a.rel = 'noopener noreferrer'; } + return a; + } + async function mount({ container, requestIds }) { + if (!(container instanceof Element)) return; + const generation = (generations.get(container) || 0) + 1; + generations.set(container, generation); + container.classList.add('mosaic-files'); + container.replaceChildren(element('h3', 'Files'), element('p', 'Reading files…')); + const ids = Array.isArray(requestIds) ? [...new Set(requestIds.filter(id))] : []; + const results = await Promise.all(ids.map(async request => { + const base = `/api/requests/${encodeURIComponent(request)}`; + const [state, workspace] = await Promise.all([read(base), read(`${base}/workspace`)]); + return { request, base, state, workspace }; + })); + if (signedOut || generations.get(container) !== generation || document.body.classList.contains('signed-out')) return; + container.replaceChildren(element('h3', 'Files')); + if (!ids.length) { container.append(element('p', 'No request files are available.')); return; } + for (const { request, base, state, workspace } of results) { + const section = element('section', undefined, 'mosaic-files-request'); + section.append(element('h4', `Request ${request}`), element('h5', 'Reviewed delivery')); + const d = state?.delivery_evidence; + if (state?.version === 1 && state.request === request && Number.isSafeInteger(state.revision) && state.revision > 0 && typeof state.phase === 'string' && id(state.delivery) && d?.id === state.delivery && d.request === request && typeof d.artifact?.path === 'string') { + const filename = d.artifact.path.split('/').at(-1); + if (/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(filename)) { + const row = element('p', undefined, 'mosaic-files-actions'); + row.append(element('span', filename), link('Open as text', `${base}/artifact/${encodeURIComponent(d.id)}/text`, false), link('Download', `${base}/artifact/${encodeURIComponent(d.id)}`, true)); + section.append(row, element('p', 'Open shows plain text in a new tab. Downloaded HTML can be opened locally after saving.')); + } else section.append(element('p', 'Reviewed file is unavailable.')); + } else section.append(element('p', state ? 'No reviewed file is available.' : 'Reviewed files are unavailable.')); + section.append(element('p', 'No request-bound pull request link is available.')); + section.append(element('h5', 'Product workspace, draft')); + if (!workspace || workspace.available !== true || !Array.isArray(workspace.entries)) { + section.append(element('p', 'Draft workspace is unavailable.')); + } else { + section.append(element('p', 'Names only. Draft files may change and are not reviewed deliveries.')); + if (!workspace.entries.length) section.append(element('p', 'No draft files found.')); + else { + const list = element('ul'); + for (const entry of workspace.entries.slice(0, 100)) { + if (typeof entry?.name === 'string') list.append(element('li', entry.name)); + } + section.append(list); + } + if (workspace.truncated) section.append(element('p', 'The draft list was truncated.')); + } + container.append(section); + } + } + window.MosaicFiles = Object.freeze({ mount }); +})(); diff --git a/packages/webui/src/request-http.mjs b/packages/webui/src/request-http.mjs index bb98a21f0..369e41c51 100644 --- a/packages/webui/src/request-http.mjs +++ b/packages/webui/src/request-http.mjs @@ -20,7 +20,7 @@ import { BusReadError } from './bus.mjs'; import { createHash } from 'node:crypto'; import { CONSOLE_VERBS } from '../../bus/src/broker.mjs'; -export const REQUEST_ROUTE = /^\/api\/requests\/([^/]+)(?:\/(projects|draft|answer|attach|mode|cancel|revoke)|\/(artifact)\/([^/]+))?$/; +export const REQUEST_ROUTE = /^\/api\/requests\/([^/]+)(?:\/(projects|draft|answer|attach|mode|cancel|revoke)|\/(artifact)\/([^/]+)(\/(?:text))?)?$/; const WRITE_VERBS = Object.freeze({ answer: 'request.answer.console', attach: 'request.attach.console', mode: 'request.mode.console', cancel: 'request.cancel.console', revoke: 'prd.revoke.console', }); @@ -147,7 +147,7 @@ const picker = v => Array.isArray(v) && v.every(p => p && typeof p === 'object' // transports; either is null when the Console has no such transport. `verbs` is the console verb // list: the draft read exists only while it names `request.draft.console`. export async function requestHTTP(req, res, { path, requestRead, requestWrite, session, live, origin, log, verbs = CONSOLE_VERBS }) { - const [, raw, suffix, artifactSuffix, rawDelivery] = REQUEST_ROUTE.exec(path); + const [, raw, suffix, artifactSuffix, rawDelivery, textSuffix] = REQUEST_ROUTE.exec(path); const action = suffix ?? artifactSuffix ?? 'get'; const write = Object.hasOwn(WRITE_VERBS, action); let id = null; @@ -189,7 +189,9 @@ export async function requestHTTP(req, res, { path, requestRead, requestWrite, s const bytes = Buffer.from(artifact.base64, 'base64'); if (bytes.length > 1024 * 1024 || bytes.toString('base64') !== artifact.base64 || createHash('sha256').update(bytes).digest('hex') !== artifact.sha256) return refuse('unavailable'); line(''); - res.writeHead(200, { 'content-type': artifact.mime, 'content-disposition': `attachment; filename="${artifact.filename}"`, 'x-content-type-options': 'nosniff', 'cache-control': 'private, no-store' }); + res.writeHead(200, textSuffix + ? { 'content-type': 'text/plain; charset=utf-8', 'content-security-policy': "sandbox; default-src 'none'", 'x-content-type-options': 'nosniff', 'cache-control': 'private, no-store' } + : { 'content-type': artifact.mime, 'content-disposition': `attachment; filename="${artifact.filename}"`, 'x-content-type-options': 'nosniff', 'cache-control': 'private, no-store' }); return res.end(bytes); } const verb = READ_VERBS[action]; diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs index 3fb2cb5f4..628e10a79 100644 --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs @@ -34,6 +34,8 @@ const files = new Map([ ['/settings/model-proxy', ['proxy.html', 'text/html; charset=utf-8']], ['/proxy.js', ['proxy.js', 'text/javascript; charset=utf-8']], ...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]), + ['/files.js', ['files.js', 'text/javascript; charset=utf-8']], + ['/files.css', ['files.css', 'text/css; charset=utf-8']], ...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css', 'processing.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]), ['/editor.js', ['editor.js', 'text/javascript; charset=utf-8']], // Row 225 (C2-10b): the one reply renderer, shared with the Discord relay, imported by chat.js. diff --git a/packages/webui/tests/artifact-http.test.mjs b/packages/webui/tests/artifact-http.test.mjs index 64effb28b..94bb0e35e 100644 --- a/packages/webui/tests/artifact-http.test.mjs +++ b/packages/webui/tests/artifact-http.test.mjs @@ -37,3 +37,31 @@ test('artifact access refuses foreign ownership, tampered bytes and expired sess assert.equal((await attempt(async verb => verb === 'request.get.console' ? state : { mime: 'text/html', filename: 'timer.html', base64: 'eA==', sha256: '0'.repeat(64) })).status, 503); assert.equal((await attempt(async () => state, () => null)).status, 401); }); + +test('text view is inert and uses the same current delivery and digest checks', async () => { + const path = '/api/requests/r1/artifact/d1/text'; + assert.match(path, REQUEST_ROUTE); + const bytes = Buffer.from(''); + const session = { name: 'owner', provider: 'test', iss: 'test', sub: 'owner' }; + const req = { method: 'GET', headersDistinct: { 'sec-fetch-site': ['same-origin'] } }; + const state = { version: 1, request: 'r1', revision: 1, phase: 'complete', delivery: 'd1' }; + const artifact = { mime: 'text/html', filename: 'timer.html', base64: bytes.toString('base64'), sha256: createHash('sha256').update(bytes).digest('hex') }; + const attempt = async (read, live = () => session) => { + const res = { writeHead(status, headers) { this.status = status; this.headers = headers; }, end(data) { this.data = data; } }; + await requestHTTP(req, res, { path, session, live, origin: 'https://console.test', log() {}, requestRead: read }); + return res; + }; + const good = await attempt(async verb => verb === 'request.get.console' ? state : artifact); + assert.equal(good.status, 200); + assert.deepEqual(good.data, bytes); + assert.equal(good.headers['content-type'], 'text/plain; charset=utf-8'); + assert.equal(good.headers['x-content-type-options'], 'nosniff'); + assert.equal(good.headers['content-security-policy'], "sandbox; default-src 'none'"); + assert.equal(good.headers['cache-control'], 'private, no-store'); + assert.equal((await attempt(async verb => verb === 'request.get.console' ? { ...state, delivery: 'd2' } : artifact)).status, 404); + assert.equal((await attempt(async verb => verb === 'request.get.console' ? state : { ...artifact, sha256: '0'.repeat(64) })).status, 503); + let reads = 0; + assert.equal((await attempt(async verb => { reads++; return verb === 'request.get.console' ? state : artifact; }, () => reads < 2 ? session : null)).status, 401); + assert.doesNotMatch('/api/requests/r1/artifact/d1/text/extra', REQUEST_ROUTE); + assert.doesNotMatch('/api/requests/r1/artifact/d1/html', REQUEST_ROUTE); +}); diff --git a/packages/webui/tests/helpers/w15-files.mjs b/packages/webui/tests/helpers/w15-files.mjs new file mode 100644 index 000000000..bcbff182a --- /dev/null +++ b/packages/webui/tests/helpers/w15-files.mjs @@ -0,0 +1,27 @@ +import { fixture } from '../fixture.mjs'; +import { browser } from '../browser.mjs'; + +// This isolated slice has no shell mount yet. Attach the actual feature to the local Console fixture. +export async function filesFixture(t) { + const f = await fixture(); t.after(() => f.close()); + const b = await browser({ machine: false }); t.after(() => b.close()); + const errors = []; + b.on('Runtime.exceptionThrown', event => errors.push(event.exceptionDetails.text)); + await b.call('Fetch.enable', { patterns: [{ urlPattern: '*' }] }); + b.on('Fetch.requestPaused', e => { + const same = e.request.url.startsWith(f.base + '/') || e.request.url === 'about:blank'; + b.call(same ? 'Fetch.continueRequest' : 'Fetch.failRequest', same ? { requestId: e.requestId } : { requestId: e.requestId, errorReason: 'BlockedByClient' }).catch(error => errors.push(error.message)); + }); + await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `(() => { + const real = window.fetch.bind(window); + window.fixtureState = null; window.fixtureWorkspace = null; + window.fetch = async (url, init) => { + const path = String(url); + if (path === '/api/requests/request-1') return new Response(JSON.stringify(window.fixtureState), {status: 200, headers: {'content-type':'application/json'}}); + if (path === '/api/requests/request-1/workspace') return window.fixtureWorkspace === null ? new Response('{}', {status: 503}) : new Response(JSON.stringify(window.fixtureWorkspace), {status: 200, headers: {'content-type':'application/json'}}); + if (path.startsWith('/api/')) return new Response('{}', {status: 503}); + return real(url, init); + }; + })();` }); + return { f, b, errors }; +} diff --git a/packages/webui/tests/w15-files-browser.test.mjs b/packages/webui/tests/w15-files-browser.test.mjs new file mode 100644 index 000000000..cbf6aedc9 --- /dev/null +++ b/packages/webui/tests/w15-files-browser.test.mjs @@ -0,0 +1,35 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { join } from 'node:path'; +import { writeFile } from 'node:fs/promises'; +import { filesFixture } from './helpers/w15-files.mjs'; + +test('Files mount reads request identity and presents safe reviewed links and unavailable draft', async t => { + const { f, b, errors } = await filesFixture(t); + const shots = process.env.MOSAIC_UX_SHOTS; + const state = { version: 1, revision: 1, phase: 'complete', request: 'request-1', delivery: 'delivery-1', delivery_evidence: { id: 'delivery-1', request: 'request-1', artifact: { path: 'output/timer.html' } } }; + for (const width of [1440, 390]) { + await b.viewport(width, 1000); + await b.navigate(f.base + '/#/'); + await b.evaluate(`(async () => { + const css = document.createElement('link'); css.rel = 'stylesheet'; css.href = '/files.css'; document.head.append(css); + await new Promise((resolve, reject) => { const s = document.createElement('script'); s.src = '/files.js'; s.onload = resolve; s.onerror = reject; document.head.append(s); }); + window.fixtureState = ${JSON.stringify(state)}; + const main = document.querySelector('#main'); main.replaceChildren(); const mount = document.createElement('div'); mount.id = 'slice-fixture'; main.append(mount); + await window.MosaicFiles.mount({container: mount, requestIds: ['request-1']}); + })()`); + assert.match(await b.evaluate("document.querySelector('#slice-fixture').textContent"), /Reviewed delivery.*timer.html.*Open as text.*Download.*Product workspace, draft.*unavailable/s); + assert.equal(await b.evaluate("document.querySelector('#slice-fixture a').getAttribute('href')"), '/api/requests/request-1/artifact/delivery-1/text'); + assert.equal(await b.evaluate("document.querySelector('#slice-fixture a').rel"), 'noopener noreferrer'); + assert.equal(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), true); + if (shots) { + await b.screenshot(join(shots, `files-isolated-unavailable-${width}.png`)); + const { cssContentSize } = await b.call('Page.getLayoutMetrics'); + const { data } = await b.call('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true, clip: { x: 0, y: 0, width: cssContentSize.width, height: cssContentSize.height, scale: 1 } }); + await writeFile(join(shots, `files-isolated-unavailable-${width}-full.png`), Buffer.from(data, 'base64')); + } + } + await b.evaluate(`(async () => { window.fixtureState.request = 'foreign'; await MosaicFiles.mount({container: document.querySelector('#slice-fixture'), requestIds:['request-1']}); })()`); + assert.equal(await b.evaluate("document.querySelectorAll('#slice-fixture a').length"), 0); + assert.deepEqual(errors, []); +}); From 7a2dc5bad19c7591131aa1e1c3aac7764ee8957f Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 5 Nov 2026 21:13:45 -0600 Subject: [PATCH 08/32] feat(webui): show decision origin and receipt evidence (#797) --- packages/webui/src/public/bus.js | 13 ++-- packages/webui/src/public/inbox.css | 6 ++ packages/webui/src/public/inbox.js | 21 ++++++ packages/webui/tests/helpers/w15-inbox.mjs | 54 ++++++++++++++ .../webui/tests/w15-inbox-browser.test.mjs | 71 +++++++++++++++++++ 5 files changed, 159 insertions(+), 6 deletions(-) create mode 100644 packages/webui/src/public/inbox.css create mode 100644 packages/webui/src/public/inbox.js create mode 100644 packages/webui/tests/helpers/w15-inbox.mjs create mode 100644 packages/webui/tests/w15-inbox-browser.test.mjs diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index 1eb30a089..dfee6e987 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -260,7 +260,7 @@ return `
  • ${d.blocking ? blockChip('blocking') : ''}${chip(d.class, d.action)}${canDecide && d.console ? ` ${badge(d.console.kind === 'decision' ? 'Answer here' : 'Approve or Deny here', 'ok')}` : ''}

    ${approves(d.authorization)}

    -

    Raised by ${txt(d.raised_by_role)} ${when(d.at)}${d.task_ref ? ` · task ${taskLink(d.task_ref)}` : ''}${d.requirement_ref ? ` · ${txt(d.requirement_ref)}` : ''}

  • `; + ${window.MosaicInbox?.cardMeta(d) ?? `

    Raised by ${txt(d.raised_by_role)} ${when(d.at)}

    `}

    ${d.task_ref ? ` · task ${taskLink(d.task_ref)}` : ''}${d.requirement_ref ? ` · ${txt(d.requirement_ref)}` : ''}

    `; } // Row 76 (#1570, lead decision 95): a human message to a held role, only when the server says // a send can pass here (`send` in /api/config). The draft lives in `draft`, not the DOM, so the @@ -337,11 +337,11 @@ let body; if (a.result) { const r = a.result; - body = `

    ${r.kind === 'decision' ? 'Recorded' : r.action === 'approve' ? 'Approved' : 'Denied'}: you chose “${txt(r.choice)}”, ${txt(r.text)}.${r.kind === 'decision' ? '' : ` ${r.grant === 'started' ? 'The grant executor started.' : r.action === 'approve' ? 'The bus started no grant for it.' : 'Nothing starts for a denial.'}`} History below shows it once the bus is read again.

    `; + body = `

    ${r.kind === 'decision' ? 'Recorded' : r.action === 'approve' ? 'Approved' : 'Denied'}: you chose “${txt(r.choice)}”, ${txt(r.text)}.${r.kind === 'decision' ? '' : ` ${r.grant === 'started' ? 'The grant executor started.' : r.action === 'approve' ? 'The bus started no grant for it.' : 'Nothing starts for a denial.'}`} History below shows it once the bus is read again.

    ${window.MosaicInbox?.receipt(r, a.decision) ?? ''}`; } else if (a.confirm) { const c = a.confirm, label = `Confirm ${c.action}`; body = `

    ${label}

    -
    Id
    ${txt(c.id)}
    Question
    ${txt(c.question)}
    Answer
    ${txt(c.choice)} ${txt(c.text)}
    ${c.kind === 'decision' ? '' : `
    Grant
    ${grantText(c.grant)}
    `}
    + ${window.MosaicInbox?.detailMeta(a.decision) ?? ''}${window.MosaicInbox?.receipt(c, a.decision) ?? ''}
    Id
    ${txt(c.id)}
    Question
    ${txt(c.question)}
    Answer
    ${txt(c.choice)} ${txt(c.text)}
    ${c.kind === 'decision' ? '' : `
    Grant
    ${grantText(c.grant)}
    `}

    ${c.kind === 'decision' ? 'Confirm to record this choice.' : c.action === 'approve' ? 'Approving starts this grant.' : 'Denying starts nothing.'} This confirmation works once and lasts two minutes.

    `; } else { @@ -406,7 +406,7 @@ if (answer !== a) { if (res?.type === 'opaqueredirect') signOut(); else render(false); return; } if (res?.type === 'opaqueredirect') { a.error = { code: 'sign-in-required', message: 'Sign in again before answering. The sign-in proxy redirected the answer, so it never reached the Console.' }; paintDecide('#decide-status'); signOut(); return; } if (res?.ok && json?.id === a.id && json.op === 'resolved' && json.choice === c.choice) { - a.result = { kind: c.kind, action: c.action, choice: c.choice, text: c.text, grant: json.grant ?? null }; + a.result = { kind: c.kind, action: c.action, choice: c.choice, text: c.text, grant: json.grant ?? null, grantDetails: c.grant ?? null }; paintDecide('#decide-status'); announce(c.kind === 'decision' ? 'Recorded' : c.action === 'approve' ? 'Approved' : 'Denied'); render(false); } else { a.error = decideRefusal(res, json, 'outcome-unknown'); paintDecide('#decide-status'); announce(`Not answered: ${a.error.message}`); } } @@ -419,7 +419,7 @@ : 'Decisions are answered in a terminal with mosaic decide; Console only shows them.'; return `${head('Inbox', `${readNote()} ${answered}`)} ${await composer(get)} -

    For you ${rows.length}

    ${body} +

    Open decisions ${rows.length}

    ${body}

    Not shown here yet: ${gap('Decisions routed to roles')} ${gap('Closed decisions')} ${gap('Seen and DM state per row')} Open a decision for its seen, DM and closing history.

    `; } async function messagesView(get, q) { @@ -453,6 +453,7 @@ answer.console = eligible; } else if (!eligible && !answer.pending && !answer.result) answer = null; + if (answer?.id === d.id) answer.decision = d; const options = `
      ${d.options.map(o => { const cmd = `mosaic decide ${d.id} ${o.key}`, chosen = c?.choice === o.key, approving = d.authorization?.approvalChoice === o.key; const copy = !answerable ? '' : SAFE.test(String(d.id)) && SAFE.test(String(o.key)) @@ -471,7 +472,7 @@ : answerable ? `

      To answer, run one of these in a terminal. Copy only puts the command on your clipboard. Console sends nothing.${why}

      ` : !c ? `

      This decision is routed to ${txt(d.routeTo)}, not to you, so Console offers no command for it.

      ` : ''; return `${head(`Decision ${txt(short(d.id))}`, status)} -

      Question

      ${txt(d.question)}

      +

      Question

      ${txt(d.question)}

      ${window.MosaicInbox?.detailMeta(d) ?? ''}
      Class
      ${chip(d.class, d.action)}${d.blocking ? ` ${blockChip('blocking')}` : ''}
      Approval
      ${approves(d.authorization)}
      Routed to
      ${txt(d.routeTo)}
      Raised by
      ${txt(d.raisedBy.role)} ${txt(d.raisedBy.run)}
      Raised
      ${when(d.at)}
      ${d.taskRef ? `
      Task
      ${taskLink(d.taskRef)} ${txt(d.taskRef)}
      ` : ''}${d.requirementRef ? `
      Requirement
      ${txt(d.requirementRef)}
      ` : ''}
      DM
      ${d.dm ? `sent ${when(d.dm.at)}` : d.blocking ? 'not sent' : 'not needed (not blocking)'}
      Id
      ${txt(d.id)}
      ${answer?.id === d.id ? decideHtml() : ''}

      Options

      ${help}${options}

      diff --git a/packages/webui/src/public/inbox.css b/packages/webui/src/public/inbox.css new file mode 100644 index 000000000..695577b1d --- /dev/null +++ b/packages/webui/src/public/inbox.css @@ -0,0 +1,6 @@ +/* Scoped decision evidence; colors inherit the active appearance. */ +.inbox-meta, .inbox-receipt-meta { font-size: .875rem; line-height: 1.5; overflow-wrap: anywhere; } +.inbox-meta { margin: .75rem 0; } +.inbox-meta p { margin: .2rem 0; } +.inbox-receipt-meta { margin: .75rem 0; } +.s1-dec-open { display: inline-block; padding: .5rem 0; min-height: 44px; overflow-wrap: anywhere; } diff --git a/packages/webui/src/public/inbox.js b/packages/webui/src/public/inbox.js new file mode 100644 index 000000000..80e25be3d --- /dev/null +++ b/packages/webui/src/public/inbox.js @@ -0,0 +1,21 @@ +// Presentation of decision evidence. No request binding is inferred from a decision row. +(() => { + 'use strict'; + const safe = value => String(value ?? '').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, c => c === '\u007f' ? '␡' : String.fromCharCode(0x2400 + c.charCodeAt(0))).replace(/[\u0080-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, c => `[U+${c.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')}]`).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + const fact = v => typeof v === 'string' && v.trim() ? safe(v) : 'unknown'; + function metadata(d) { + const raw = d?.at, ms = typeof raw === 'string' ? Date.parse(raw) : NaN; + const date = Number.isFinite(ms) ? new Date(ms).toISOString() : null; + const seconds = Math.max(0, Math.floor((Date.now() - ms) / 1000)); + const age = !date ? 'unknown' : seconds < 60 ? `${seconds} s ago` : seconds < 3600 ? `${Math.floor(seconds / 60)} min ago` : seconds < 86400 ? `${Math.floor(seconds / 3600)} h ago` : `${Math.floor(seconds / 86400)} d ago`; + const role = d?.raisedBy?.role ?? d?.raised_by_role, run = d?.raisedBy?.run ?? d?.raised_by_run; + return `

      Raised ${date ? `` : 'date unknown'} · ${age}

      Raised by ${fact(role)} · run ${fact(run)}

      Not linked to a request

      `; + } + function receipt(result, decision) { + const target = decision?.authorization?.target; + const grant = result?.grantDetails ?? result?.grant; + const destination = typeof grant?.host === 'string' && grant.host.trim() ? grant.host : Array.isArray(grant?.projects) && grant.projects.length ? grant.projects.join(', ') : null; + return `

      Affected object: ${typeof target === 'string' && target.trim() ? safe(target) : 'unavailable'}. Decision routed to: ${fact(decision?.routeTo)}. Destination: ${typeof destination === 'string' && destination.trim() ? safe(destination) : 'unavailable'}. ${result?.kind === 'decision' ? 'Execution or publication is unknown until separately evidenced.' : 'Grant execution or publication requires separate evidence.'}

      `; + } + window.MosaicInbox = { cardMeta: metadata, detailMeta: metadata, receipt }; +})(); diff --git a/packages/webui/tests/helpers/w15-inbox.mjs b/packages/webui/tests/helpers/w15-inbox.mjs new file mode 100644 index 000000000..da6786c91 --- /dev/null +++ b/packages/webui/tests/helpers/w15-inbox.mjs @@ -0,0 +1,54 @@ +import { readFile, mkdir, writeFile } from 'node:fs/promises'; +import { fixture } from '../fixture.mjs'; +import { browser } from '../browser.mjs'; + +export const oldId = 'ec3c0ac1-030b-495c-b2de-3711acc0646d'; +export const requestId = 'b1869a13-b328-408a-b4f7-50ded79aeb15'; +export const currentId = '89cc5c95-31e8-4d0d-a2ca-25f5c3f3253e'; +export const old = { id: oldId, at: '2026-11-03T22:17:22.717Z', raised_by_role: 'pm', raised_by_run: 'rcfdc22434131', class: 'within-role', action: 'prd.approve', route_to: 'human', question: 'Historic decision: choose a path', options: [{ key: 'keep', text: 'Keep original' }], blocking: false, console: { kind: 'decision', digest: 'a'.repeat(64), options: [{ key: 'keep', text: 'Keep original' }] } }; +export const current = { ...old, id: currentId, at: '2026-11-06T12:00:00.000Z', raised_by_role: 'chief', raised_by_run: 'current-run', question: 'Current request: choose a mode', options: [{ key: 'guided', text: 'Guided' }], console: { kind: 'decision', digest: 'b'.repeat(64), options: [{ key: 'guided', text: 'Guided' }] } }; +export async function setup(t) { + const f = await fixture(), b = await browser({ machine: false }); + t.after(async () => { await b.close(); await f.close(); }); + const script = await readFile(new URL('../../src/public/inbox.js', import.meta.url), 'utf8'); + const css = await readFile(new URL('../../src/public/inbox.css', import.meta.url), 'utf8'); + const source = `(() => { + ${script} + document.addEventListener('DOMContentLoaded', () => { const style = document.createElement('style'); style.textContent = ${JSON.stringify(css)}; document.head.append(style); }); + const rows = ${JSON.stringify([old, current])}; + window.__request = { id: ${JSON.stringify(requestId)}, currentWaitSubject: ${JSON.stringify(currentId)} }; + window.__posts = []; window.__outcome = 'success'; + const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: {'content-type':'application/json'} }); + const real = window.fetch.bind(window); + window.fetch = async (url, init = {}) => { + const u = new URL(String(url), location.href); + if (u.origin !== location.origin) throw new Error('External network refused'); + if (!u.pathname.startsWith('/api/')) return real(url, init); + if (u.pathname === '/api/processing' || u.pathname === '/api/bus-status') return json({error:'unavailable', message:'Fixture service unavailable'}, 503); + if (u.pathname === '/api/config') return json({sso:true, send:true, decide:true}); + if (u.pathname === '/api/bus/inbox') return json({rows, at:'2026-11-06T13:00:00.000Z'}); + if (u.pathname === '/api/bus/agents' || u.pathname === '/api/bus/messages') return json({rows:[], unread:0, at:'2026-11-06T13:00:00.000Z'}); + if (u.pathname === '/api/bus/trail') { const row = rows.find(x => x.id === u.searchParams.get('subject')); return json({ rows: row ? [{ ...row, table:'decisions' }] : [], at:'2026-11-06T13:00:00.000Z' }); } + if (u.pathname.startsWith('/api/decisions/')) { + window.__posts.push({path:u.pathname, body:JSON.parse(init.body)}); + const id = u.pathname.split('/')[3], row = rows.find(x => x.id === id), choice = JSON.parse(init.body).choice, option = row?.options.find(x => x.key === choice); + if (!option) return json({error:'refused',message:'Unknown choice'}, 403); + if (u.pathname.endsWith('/confirmation')) return json({id,kind:'decision',action:'answer',question:row.question,choice,text:option.text,confirmation:'c'.repeat(64)}); + if (window.__outcome === 'unknown') return json({error:'outcome-unknown',message:'The answer may have reached the bus. Refresh before trying again.'}, 503); + return json({id,op:'resolved',choice}); + } + window.__unexpected = (window.__unexpected || []).concat(u.pathname); return json({error:'unexpected',message:'Fixture refused'}, 403); + }; + })();`; + await b.call('Page.addScriptToEvaluateOnNewDocument', { source }); + await b.call('Network.enable'); + await b.call('Fetch.enable', { patterns: [{ urlPattern: '*' }] }); + const failures = [], consoleErrors = []; + b.on('Runtime.exceptionThrown', p => consoleErrors.push(p.exceptionDetails.text)); + b.on('Runtime.consoleAPICalled', p => { if (p.type === 'error') consoleErrors.push(p.args.map(a => a.value ?? a.description ?? '').join(' ')); }); + b.on('Fetch.requestPaused', p => { const url = p.request.url; const allowed = url.startsWith(f.base + '/') || url === 'about:blank' || url.startsWith('data:'); if (!allowed) failures.push(url); b.call(allowed ? 'Fetch.continueRequest' : 'Fetch.failRequest', allowed ? {requestId:p.requestId} : {requestId:p.requestId,errorReason:'BlockedByClient'}).catch(() => {}); }); + const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw Error('Timed out: '+${JSON.stringify(expr)})})()`); + async function page(route, width) { await b.viewport(width,1000); await b.navigate(f.base + '/#' + route); await wait('document.querySelector("#bus-view h1") && !document.querySelector("#bus-view [aria-busy]")'); await b.evaluate('document.fonts.ready'); } + async function shot(state, width) { if (!process.env.MOSAIC_UX_SHOTS) return; const dir = process.env.MOSAIC_UX_SHOTS; await mkdir(dir,{recursive:true}); const name = `inbox-${state}-${width}`; await b.screenshot(`${dir}/${name}.png`); const {contentSize} = await b.call('Page.getLayoutMetrics'); const {data} = await b.call('Page.captureScreenshot',{format:'png',captureBeyondViewport:true,clip:{x:0,y:0,width:Math.ceil(contentSize.width),height:Math.ceil(contentSize.height),scale:1}}); await writeFile(`${dir}/${name}-full.png`,Buffer.from(data,'base64')); } + return { f,b,wait,page,shot,failures,consoleErrors }; +} diff --git a/packages/webui/tests/w15-inbox-browser.test.mjs b/packages/webui/tests/w15-inbox-browser.test.mjs new file mode 100644 index 000000000..520840ecc --- /dev/null +++ b/packages/webui/tests/w15-inbox-browser.test.mjs @@ -0,0 +1,71 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { setup, oldId, currentId, requestId } from './helpers/w15-inbox.mjs'; + +for (const width of [1440, 390]) test(`decision evidence and two-step answer at ${width}px`, { timeout: 120000 }, async t => { + const s = await setup(t), { b, wait } = s; + const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? ''`); + await s.page('/inbox', width); + await wait('document.querySelectorAll(".s1-decs > li").length === 2'); + assert.match(await text('#b-for-you'), /Open decisions/); + const cards = await b.evaluate('[...document.querySelectorAll(".s1-decs > li")].map(e => e.textContent)'); + assert.match(cards[0], /2026-11-03T22:17:22\.717Z.*\d+ d ago.*pm.*rcfdc22434131.*Not linked to a request/s); + assert.match(cards[1], /current-run/); + assert.doesNotMatch(cards[0], /current-run|b1869a13/); + assert.deepEqual(await b.evaluate('window.__request'), { id: requestId, currentWaitSubject: currentId }); + assert.notEqual(currentId, requestId); + assert.equal(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), true); + await s.shot('open-decisions-old-and-current', width); + await s.page(`/inbox/${oldId}`, width); + await wait('document.querySelector("[data-decide=option-0]")'); + assert.match(await text('#bus-view'), /rcfdc22434131/); + await s.shot('historic-decision-detail', width); + await b.evaluate('document.querySelector("[data-decide=option-0]").click()'); + await wait('document.querySelector("[data-decide=confirm]")'); + assert.match(await text('.s1-confirm'), /2026-11-03T22:17:22\.717Z.*rcfdc22434131.*Not linked to a request.*Affected object: unavailable.*Decision routed to: human.*Destination: unavailable.*keep.*Keep original/s); + assert.equal(await b.evaluate('window.__posts.length'), 1); + assert.equal(await b.evaluate('document.activeElement?.dataset.decide'), 'confirm'); + await b.key('Tab', 'Tab'); + assert.equal(await b.evaluate('document.activeElement?.dataset.decide'), 'cancel'); + await b.key('Tab', 'Tab', 8); + assert.equal(await b.evaluate('document.activeElement?.dataset.decide'), 'confirm'); + await b.key('Escape'); + assert.equal(await b.evaluate('window.__posts.length'), 1); + await s.shot('historic-confirmation', width); + await b.evaluate('document.querySelector("[data-decide=confirm]").click()'); + await wait('document.querySelector("#decide-status")?.textContent.startsWith("Recorded")'); + assert.match(await text('#decide-section'), /Recorded: you chose “keep”, Keep original.*Affected object: unavailable.*Decision routed to: human.*Destination: unavailable.*unknown/s); + assert.equal(await b.evaluate('window.__posts.length'), 2); + await s.shot('decision-recorded', width); + await s.page(`/inbox/${currentId}`, width); + await wait('document.querySelector("[data-decide=option-0]")'); + assert.match(await text('#bus-view'), /Current request: choose a mode/); + assert.doesNotMatch(await text('#bus-view'), /rcfdc22434131/); + await s.shot('current-decision', width); + await b.evaluate('window.__outcome = "unknown"; document.querySelector("[data-decide=option-0]").click()'); + await wait('document.querySelector("[data-decide=confirm]")'); + await b.evaluate('document.querySelector("[data-decide=confirm]").click()'); + await wait('document.querySelector("#decide-status code")?.textContent === "outcome-unknown"'); + assert.match(await text('#decide-status'), /Nothing was sent again/); + await s.shot('decision-outcome-unknown', width); + assert.deepEqual(await b.evaluate('window.__unexpected || []'), []); + assert.deepEqual(s.failures, []); + assert.deepEqual(s.consoleErrors, []); +}); + +test('metadata handles missing and hostile origins without HTML or bidi injection', { timeout: 120000 }, async t => { + const { b, page } = await setup(t); + await page('/inbox', 1440); + const result = await b.evaluate(`(() => { const host = document.createElement('div'); host.innerHTML = window.MosaicInbox.cardMeta({at:'bad', raised_by_role:'\\u202e', raised_by_run:''}); return {text:host.textContent, images:host.querySelectorAll('img').length}; })()`); + assert.match(result.text, /date unknown.*unknown.*\[U\+202E\].*run unknown.*Not linked to a request/s); + assert.equal(result.images, 0); +}); + + +test('receipt uses recorded target and destination without interpreting hostile text', { timeout: 120000 }, async t => { + const { b, page } = await setup(t); + await page('/inbox', 1440); + const result = await b.evaluate(`(() => { const host = document.createElement('div'); host.innerHTML = window.MosaicInbox.receipt({kind:'decision',grantDetails:{host:'web1'}}, {authorization:{target:'workspace \\u202e'},routeTo:'human'}); return {text:host.textContent, images:host.querySelectorAll('img').length}; })()`); + assert.match(result.text, /Affected object: workspace \[U\+202E\].*Decision routed to: human.*Destination: web1.*unknown/s); + assert.equal(result.images, 0); +}); From 191061a9e7321f2bc7eac4542414d40587b75d92 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 5 Nov 2026 21:15:36 -0600 Subject: [PATCH 09/32] feat(webui): add W15 shell candidate (#797) --- packages/webui/src/public/bus.js | 70 ++++++++++++++++--- packages/webui/src/public/display.js | 51 ++++++++++++++ packages/webui/src/public/index.html | 10 +-- packages/webui/src/public/shell.css | 26 +++++++ packages/webui/tests/shell.test.mjs | 15 ++-- packages/webui/tests/w15-fixture.mjs | 62 ++++++++++++++++ .../webui/tests/w15-shell-browser.test.mjs | 57 +++++++++++++++ 7 files changed, 268 insertions(+), 23 deletions(-) create mode 100644 packages/webui/src/public/display.js create mode 100644 packages/webui/tests/w15-fixture.mjs create mode 100644 packages/webui/tests/w15-shell-browser.test.mjs diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index 1eb30a089..836829a0b 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -247,9 +247,9 @@ // ---------------------------------------------------------------- views let inboxCount = null, messageCount = null; function sections(route) { - const items = [['/', 'Board', '', 'board'], ['/inbox', 'Inbox', inboxCount ? `${inboxCount.open}${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/chat', 'Chat', '', 'inbox'], ['/messages', 'Messages', messageCount === null ? '' : `${messageCount}`, 'inbox'], ['/tasks', 'Tasks', '', 'tasks'], ['/agents', 'Agents', '', 'agents'], ['/queue', 'Queue', '', 'queue'], ['/business', 'Business', '', 'business'], ['/docs', 'Docs', '', 'library'], ['/settings', 'Settings', '', 'settings']]; + const items = [['/', 'Board', '', 'board'], ['/inbox', 'Inbox', inboxCount ? `${inboxCount.open}${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/projects', 'Projects', '', 'inbox'], ['/messages', 'Messages', messageCount === null ? '' : `${messageCount}`, 'inbox'], ['/tasks', 'Tasks', '', 'tasks'], ['/agents', 'Agents', '', 'agents'], ['/queue', 'Queue', '', 'queue'], ['/business', 'Business', '', 'business'], ['/docs', 'Docs', '', 'library'], ['/settings', 'Settings', '', 'settings']]; $('sections').innerHTML = items.map(([h, l, extra, icon]) => { - const cur = h === '/' ? route === '/' : route.startsWith(h) || (h === '/tasks' && route.startsWith('/trail/task')) || (h === '/inbox' && (route.startsWith('/trail/decision') || route.startsWith('/trail/message'))); + const cur = h === '/' ? route === '/' : route.startsWith(h) || (h === '/projects' && route.startsWith('/chat')) || (h === '/tasks' && route.startsWith('/trail/task')) || (h === '/inbox' && (route.startsWith('/trail/decision') || route.startsWith('/trail/message'))); return `
    1. ${l}${extra ? ` ${extra}` : ''}
    2. `; }).join(''); } @@ -259,7 +259,7 @@ function decisionCard(d) { return `
    3. ${d.blocking ? blockChip('blocking') : ''}${chip(d.class, d.action)}${canDecide && d.console ? ` ${badge(d.console.kind === 'decision' ? 'Answer here' : 'Approve or Deny here', 'ok')}` : ''}

      -

      ${approves(d.authorization)}

      +

      ${approves(d.authorization)}

      ${window.MosaicInbox?.cardMeta?.(d) ?? ''}

      Raised by ${txt(d.raised_by_role)} ${when(d.at)}${d.task_ref ? ` · task ${taskLink(d.task_ref)}` : ''}${d.requirement_ref ? ` · ${txt(d.requirement_ref)}` : ''}

    4. `; } // Row 76 (#1570, lead decision 95): a human message to a held role, only when the server says @@ -337,7 +337,7 @@ let body; if (a.result) { const r = a.result; - body = `

      ${r.kind === 'decision' ? 'Recorded' : r.action === 'approve' ? 'Approved' : 'Denied'}: you chose “${txt(r.choice)}”, ${txt(r.text)}.${r.kind === 'decision' ? '' : ` ${r.grant === 'started' ? 'The grant executor started.' : r.action === 'approve' ? 'The bus started no grant for it.' : 'Nothing starts for a denial.'}`} History below shows it once the bus is read again.

      `; + body = `${window.MosaicInbox?.receipt?.(r, a.decision) ?? ''}

      ${r.kind === 'decision' ? 'Recorded' : r.action === 'approve' ? 'Approved' : 'Denied'}: you chose “${txt(r.choice)}”, ${txt(r.text)}.${r.kind === 'decision' ? '' : ` ${r.grant === 'started' ? 'The grant executor started.' : r.action === 'approve' ? 'The bus started no grant for it.' : 'Nothing starts for a denial.'}`} History below shows it once the bus is read again.

      `; } else if (a.confirm) { const c = a.confirm, label = `Confirm ${c.action}`; body = `

      ${label}

      @@ -453,6 +453,7 @@ answer.console = eligible; } else if (!eligible && !answer.pending && !answer.result) answer = null; + if (answer?.id === d.id) answer.decision = d; const options = `
        ${d.options.map(o => { const cmd = `mosaic decide ${d.id} ${o.key}`, chosen = c?.choice === o.key, approving = d.authorization?.approvalChoice === o.key; const copy = !answerable ? '' : SAFE.test(String(d.id)) && SAFE.test(String(o.key)) @@ -471,7 +472,7 @@ : answerable ? `

        To answer, run one of these in a terminal. Copy only puts the command on your clipboard. Console sends nothing.${why}

        ` : !c ? `

        This decision is routed to ${txt(d.routeTo)}, not to you, so Console offers no command for it.

        ` : ''; return `${head(`Decision ${txt(short(d.id))}`, status)} -

        Question

        ${txt(d.question)}

        +

        Question

        ${txt(d.question)}

        ${window.MosaicInbox?.detailMeta?.(d) ?? ''}
        Class
        ${chip(d.class, d.action)}${d.blocking ? ` ${blockChip('blocking')}` : ''}
        Approval
        ${approves(d.authorization)}
        Routed to
        ${txt(d.routeTo)}
        Raised by
        ${txt(d.raisedBy.role)} ${txt(d.raisedBy.run)}
        Raised
        ${when(d.at)}
        ${d.taskRef ? `
        Task
        ${taskLink(d.taskRef)} ${txt(d.taskRef)}
        ` : ''}${d.requirementRef ? `
        Requirement
        ${txt(d.requirementRef)}
        ` : ''}
        DM
        ${d.dm ? `sent ${when(d.dm.at)}` : d.blocking ? 'not sent' : 'not needed (not blocking)'}
        Id
        ${txt(d.id)}
        ${answer?.id === d.id ? decideHtml() : ''}

        Options

        ${help}${options}

        @@ -972,7 +973,7 @@ ? `
        Binding
        ${txt(n.binding)}

        The binding name only. Its channels, server and user ids stay in the binding file.

        ` : empty('No notifier binding to show.', `${n.refused ? `${txt(n.refused)}: ` : ''}${txt(n.message || 'the notifier settings could not be read')}`); return `${head('Settings', `${readLine()} Agent limits and appearance.`)}${appearance()}${launchForm(s.launch)}${routesSection(routes)}${assistantSection(s.launch, names)}${instancesSection(instances)}${agentsSection(instances)}${tokenLimits(limitRows, limitsRefused)}${tokenUsage(usage)} -

        Model proxy

        Manage this instance’s model accounts, priorities and client key IDs.

        Open model proxy settings
        +

        Sidebar display

        Choose how this browser shows the sidebar.

        Sidebar display

        Model proxy

        Manage this instance’s model accounts, priorities and client key IDs.

        Open model proxy settings

        Notifications

        ${notify}

        About

        Address
        ${txt(s.address) || 'unknown'}
        Release
        ${s.release ? `${txt(s.release)}` : 'unknown: no RELEASE file was read'}
        Business
        ${txt(s.business) || none}
        Board
        ${txt(s.board) || 'unknown'}
        `; } @@ -1251,11 +1252,16 @@ document.body.classList.toggle('on-bus', !board); $('bus-view').hidden = board; sections(path); - window.MosaicChat?.leave(path); - if (path === '/chat' || path.startsWith('/chat/')) { + const chatPath = path === '/projects' ? '/chat' : /^\/projects\/(threads|rooms)\//.test(path) ? '/chat' + path.slice('/projects'.length) : /^\/projects\/[^/]+$/.test(path) ? '/chat/projects/' + path.slice('/projects/'.length) : path; + window.MosaicChat?.leave(chatPath); + if (path === '/settings/display' && window.MosaicDisplay) { + window.MosaicDisplay.render($('bus-view'), navigated); shown = route; return; + } + if (chatPath === '/chat' || chatPath.startsWith('/chat/')) { // Row 177 (#1675): the tester Console writes to its own threads. - await window.MosaicChat.render({ path, container: $('bus-view'), canSend: canSend || isTester, tester: isTester, navigated }); + await window.MosaicChat.render({ path: chatPath, container: $('bus-view'), canSend: canSend || isTester, tester: isTester, navigated }); if (g !== gen || out()) return; + if (chatPath === '/chat') { const h = $('bus-view').querySelector('h1'); if (h) { h.textContent = 'Projects'; document.title = 'Projects · Mosaic Console'; } } shown = route; schedule(); return; } if (board) { @@ -1355,7 +1361,7 @@ // Ctrl+K opens it. It lists the views, and the decisions and tasks from the last inbox and // tasks reads (opening it reads both again, GET only). Enter goes; Esc closes and puts focus // back where it was. It only navigates. - const VIEWS = [['#/', 'Board'], ['#/chat', 'Chat'], ['#/inbox', 'Inbox'], ['#/messages', 'Messages'], ['#/tasks', 'Tasks'], ['#/agents', 'Agents'], ['#/queue', 'Queue'], ['#/business', 'Business'], ['#/docs', 'Docs'], ['#/settings', 'Settings']]; + const VIEWS = [['#/', 'Board'], ['#/projects', 'Projects'], ['#/inbox', 'Inbox'], ['#/messages', 'Messages'], ['#/tasks', 'Tasks'], ['#/agents', 'Agents'], ['#/queue', 'Queue'], ['#/business', 'Business'], ['#/docs', 'Docs'], ['#/settings', 'Settings'], ['#/settings/display', 'Sidebar display']]; let pkAll = [], pkShown = [], pkAt = 0, pkBack = null; function pkItems() { const decisions = (last.get('inbox')?.rows || []).map(d => ({ href: `#/inbox/${enc(d.id)}`, label: first(d.question), group: `decision ${short(d.id)}` })); @@ -1461,8 +1467,50 @@ } if (!canSend && !isTester && !ssoOn) return; const path = routeOf().split('?')[0]; - if (path.startsWith('/chat')||path.startsWith('/inbox')||path==='/settings'||path.startsWith('/docs/')) render(false); else if (path === '/' || path === '') paintHeld(heldErr); + if (path.startsWith('/chat')||path.startsWith('/projects')||path.startsWith('/inbox')||path==='/settings'||path.startsWith('/docs/')) render(false); else if (path === '/' || path === '') paintHeld(heldErr); }).catch(() => {}); + // Normalize the renderer's legacy project links without changing the chat API. + function projectChrome() { + const path = routeOf().split('?')[0]; + if (!(path === '/chat' || path.startsWith('/chat/') || path === '/projects' || path.startsWith('/projects/'))) return; + const view = $('bus-view'); + for (const a of view.querySelectorAll('a[href^="#/chat"]')) { + const h = a.getAttribute('href'); + const canonical = h === '#/chat' ? '#/projects' : h.startsWith('#/chat/projects/') ? '#/projects/' + h.slice('#/chat/projects/'.length) : h.replace(/^#\/chat\/(threads|rooms)\//, '#/projects/$1/'); + if (canonical !== h) a.setAttribute('href', canonical); + if (a.textContent === 'Chat projects') a.textContent = 'Projects'; + } + if (path === '/chat' || path === '/projects') { + const h = view.querySelector('h1'); + if (h && h.textContent === 'Chat') { h.textContent = 'Projects'; document.title = 'Projects · Mosaic Console'; } + } + } + new MutationObserver(projectChrome).observe($('bus-view'), { childList: true, subtree: true }); + // The drawer keeps the background inert and returns focus to its opener. + const nav = $('console-nav'), opener = $('nav-open'), closer = $('nav-close'), scrim = $('nav-scrim'); + let drawerOpen = false; + const background = [document.querySelector('.cmdbar'), $('main'), $('inspector'), document.querySelector('.foot')]; + function drawer(open, restore = true) { + drawerOpen = open; document.body.classList.toggle('nav-open', open); + opener.setAttribute('aria-expanded', String(open)); scrim.hidden = !open; + background.forEach(el => { if (el) el.inert = open; }); + if (open) { $('nav-overflow').hidden = nav.scrollHeight <= nav.clientHeight + 1; closer.focus(); } + else if (restore) opener.focus(); + } + opener.addEventListener('click', () => drawer(true)); + closer.addEventListener('click', () => drawer(false)); + scrim.addEventListener('click', () => drawer(false)); + document.addEventListener('keydown', e => { + if (!drawerOpen) return; + if (e.key === 'Escape') { e.preventDefault(); drawer(false); return; } + if (e.key !== 'Tab') return; + const controls = [...nav.querySelectorAll('a[href],button:not(:disabled),input:not(:disabled),select:not(:disabled),[tabindex="0"]')].filter(el => el.getClientRects().length); + if (!controls.length) return; + if (e.shiftKey && document.activeElement === controls[0]) { e.preventDefault(); controls.at(-1).focus(); } + else if (!e.shiftKey && document.activeElement === controls.at(-1)) { e.preventDefault(); controls[0].focus(); } + }); + nav.addEventListener('click', e => { if (drawerOpen && e.target.closest('a[href^="#/"]')) drawer(false, false); }); + window.addEventListener('resize', () => { if (drawerOpen && innerWidth >= 760) drawer(false, false); else if (drawerOpen) $('nav-overflow').hidden = nav.scrollHeight <= nav.clientHeight + 1; }); window.addEventListener('hashchange', () => render(true)); $('refresh').addEventListener('click', () => render(false)); $('pause').addEventListener('click', () => setTimeout(schedule)); // after app.js flips aria-pressed diff --git a/packages/webui/src/public/display.js b/packages/webui/src/public/display.js new file mode 100644 index 000000000..438e1b2ad --- /dev/null +++ b/packages/webui/src/public/display.js @@ -0,0 +1,51 @@ +/* Browser-local sidebar display preferences. No project or authority data is stored here. */ +(() => { + const key = 'mosaic-sidebar-display-v1'; + const defaults = () => ({ version: 1, density: 'comfortable', order: 'title-first' }); + const valid = x => x && x.version === 1 && ['comfortable', 'compact'].includes(x.density) && ['title-first', 'contact-first'].includes(x.order) && Object.keys(x).sort().join(',') === 'density,order,version'; + let saved = defaults(); + try { const value = JSON.parse(localStorage.getItem(key)); if (valid(value)) saved = value; } catch { /* inaccessible or invalid storage uses defaults */ } + let draft = { ...saved }, pending = null, dirty = false; + const apply = () => { document.documentElement.dataset.sidebarDensity = saved.density; document.documentElement.dataset.sidebarOrder = saved.order; document.dispatchEvent(new CustomEvent('mosaic-display-change', { detail: { ...saved } })); }; + apply(); + function save() { + try { localStorage.setItem(key, JSON.stringify(draft)); saved = { ...draft }; dirty = false; apply(); return true; } + catch { return false; } + } + function status(message) { const el = document.getElementById('display-status'); if (el) el.textContent = message; } + function render(container, navigated) { + container.innerHTML = `

        Sidebar display

        These choices apply in this browser.

        Density
        Sidebar order

        Preview uses the current sidebar destinations.

        `; + const form = document.getElementById('display-form'); + for (const name of ['density', 'order']) form.elements[name].value = draft[name]; + const preview = document.getElementById('display-preview'); + preview.textContent = [...document.querySelectorAll('#sections .s1-section')].map(el => el.textContent.trim()).join(' · '); + form.addEventListener('change', () => { draft = { version: 1, density: form.elements.density.value, order: form.elements.order.value }; dirty = JSON.stringify(draft) !== JSON.stringify(saved); status(dirty ? 'Unsaved changes.' : ''); }); + form.addEventListener('submit', e => { e.preventDefault(); status(save() ? 'Sidebar display saved.' : 'Could not save to local browser storage. Your choices are kept. Try Save again, edit, or discard when leaving.'); }); + document.title = 'Sidebar display · Mosaic Console'; + if (navigated) container.querySelector('h1').focus(); + } + const dialog = document.createElement('dialog'); dialog.className = 'display-dialog'; + dialog.setAttribute('aria-labelledby', 'display-leave-title'); + dialog.innerHTML = '

        Unsaved sidebar display

        Your choices are kept while you decide.

        '; + document.body.append(dialog); + let current = location.hash || '#/'; + window.addEventListener('hashchange', e => { + const destination = location.hash || '#/'; + if (current === '#/settings/display' && destination !== current && dirty) { + e.stopImmediatePropagation(); pending = destination; history.replaceState(null, '', current); dialog.showModal(); dialog.querySelector('[data-choice=save]').focus(); return; + } + current = destination; + }); + dialog.addEventListener('cancel', e => { e.preventDefault(); dialog.close(); pending = null; document.querySelector('#display-form input')?.focus(); }); + dialog.addEventListener('click', e => { + const choice = e.target.dataset.choice; if (!choice) return; + if (choice === 'save' && !save()) { document.getElementById('display-leave-status').textContent = 'Could not save to local browser storage. Your choices are kept. Retry, keep editing, or discard.'; return; } + if (choice === 'discard') { draft = { ...saved }; dirty = false; } + dialog.close(); + if (choice === 'edit') { pending = null; document.querySelector('#display-form input')?.focus(); return; } + const destination = pending; pending = null; if (choice === 'save') sessionStorageNotice(); location.hash = destination; + }); + function sessionStorageNotice() { const a = document.getElementById('announce'); if (a) a.textContent = 'Sidebar display saved.'; } + document.addEventListener('mosaic-signed-out', () => { draft = { ...saved }; dirty = false; pending = null; if (dialog.open) dialog.close(); }); + window.MosaicDisplay = { render }; +})(); diff --git a/packages/webui/src/public/index.html b/packages/webui/src/public/index.html index d4ab18ba8..423992a7b 100644 --- a/packages/webui/src/public/index.html +++ b/packages/webui/src/public/index.html @@ -1,26 +1,26 @@ Mosaic Console - + - + -
        MosaicConsole +
        MosaicConsole

        -

        Control board

        Loading board…

        Held roles –

        The stack's seats: each role a run holds on the bus, from the bus agents read. Machine sessions are under Show machine sessions.

        Reading the held roles.
        diff --git a/packages/webui/src/public/shell.css b/packages/webui/src/public/shell.css index ed6d2d96e..c822ea798 100644 --- a/packages/webui/src/public/shell.css +++ b/packages/webui/src/public/shell.css @@ -175,3 +175,29 @@ body.on-bus #board-refused{display:none} @media (max-width:359px){.cmd-right{flex-wrap:wrap}.cmd-right label{flex:1 1 110px}} @media (prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}} @media (forced-colors:active){.tag,.empty,.banner,.toast,.pk{border:1px solid CanvasText}.s1-section[aria-current=page]{outline:2px solid Highlight}table.sessions tbody tr.is-selected{outline:2px solid Highlight}} + +/* W15 shell. Archive integration may style .project-archive-control and + .project-archived-filter with these shared button and filter treatments. */ +.project-archive-control,.project-archived-filter{min-height:44px;border-radius:var(--r)} +#nav-open,.drawer-head{display:none} +.s1-section{min-height:44px} +[data-sidebar-density=compact] .s1-section{min-height:44px;padding-block:3px} +.display-settings fieldset{border:1px solid var(--line);border-radius:var(--r);margin:0 0 16px;padding:12px} +.display-settings label{display:block;min-height:44px;padding:10px 0} +.display-preview{padding:12px;border:1px solid var(--line);border-radius:var(--r);margin-bottom:16px;overflow-wrap:anywhere} +.display-dialog{max-width:min(32rem,calc(100% - 24px));border:1px solid var(--line);border-radius:var(--r);background:var(--surface);color:var(--text);padding:20px} +.display-dialog::backdrop{background:rgb(0 0 0 / .5)} +.display-dialog button{min-height:44px;margin:4px} +@media (min-width:960px){.frame,body.on-bus .frame{grid-template-columns:226px minmax(0,1fr)}.content{padding:22px}} +@media (max-width:759px){ + #nav-open{display:inline-flex;min-height:44px;align-items:center} + .drawer-head{display:flex;align-items:center;justify-content:space-between;position:sticky;top:-12px;z-index:2;background:var(--surface);padding:8px 0} + #nav-close{min-height:44px} + .tree{display:none;position:fixed;inset:0 auto 0 0;z-index:100;width:min(330px,calc(100vw - 32px));height:100dvh;max-height:none;overflow-y:auto;flex-direction:column;flex-wrap:nowrap;align-items:stretch;padding:12px;background:var(--surface);box-shadow:var(--shadow)} + body.nav-open .tree{display:flex} + #nav-scrim:not([hidden]){display:block;position:fixed;inset:0;background:rgb(0 0 0 / .5);z-index:99} + .s1-sections{display:grid;overflow:visible;flex-basis:auto} + .s1-sections>li{width:100%} + .s1-section{white-space:normal} + .content h1{font-size:22px} +} diff --git a/packages/webui/tests/shell.test.mjs b/packages/webui/tests/shell.test.mjs index 996b5c958..0ae96f9e4 100644 --- a/packages/webui/tests/shell.test.mjs +++ b/packages/webui/tests/shell.test.mjs @@ -273,17 +273,18 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct } assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#fresh-board")).display'), 'none'); assert.equal(await b.evaluate('document.querySelectorAll("#sections svg use").length'), 10); - assert.deepEqual(await b.evaluate(`(() => { const a = document.querySelector('#sections a[href="#/chat"]'); return [a?.textContent.trim(), a?.querySelector('svg use')?.getAttribute('href')]; })()`), ['Chat', '/icons.svg#i-inbox']); + assert.deepEqual(await b.evaluate(`(() => { const a = document.querySelector('#sections a[href="#/projects"]'); return [a?.textContent.trim(), a?.querySelector('svg use')?.getAttribute('href')]; })()`), ['Projects', '/icons.svg#i-inbox']); for (const sel of ['#fresh', '#session-count']) assert.equal(await b.evaluate(`getComputedStyle(document.querySelector(${JSON.stringify(sel)})).fontVariantNumeric`), 'tabular-nums', sel); // Before any copy the toast is an empty live region still in the accessibility tree, out of sight. assert.deepEqual(await b.evaluate('(()=>{const t=document.querySelector("#toast"),c=getComputedStyle(t),r=t.getBoundingClientRect();return [c.display!=="none",c.visibility,t.textContent,r.width<=1&&r.height<=1]})()'), [true, 'visible', '', true]); - // Below 760px the section list is one strip that scrolls inside itself: every link on one - // line, no label wrapped, and still no sideways scroll on the page. 360px, then back to 400px. + // At narrow widths the full navigation is in a scrollable drawer. for (const w of [360, 400]) { await b.viewport(w, 900); - assert.equal(await b.evaluate('new Set([...document.querySelectorAll("#sections a")].map(a=>a.offsetTop)).size'), 1, `one row at ${w}px`); - assert.match(await b.evaluate('getComputedStyle(document.querySelector("#sections")).overflowX'), /^(auto|scroll)$/, `strip scrolls at ${w}px`); - assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#sections a")].filter(a=>a.getClientRects().length>1||a.scrollWidth>a.clientWidth+1).map(a=>a.textContent)'), [], `no wrapped label at ${w}px`); + await b.evaluate('document.querySelector("#nav-open").click()'); + assert.equal(await b.evaluate('document.querySelector("#nav-close").getClientRects().length > 0'), true); + assert.equal(await b.evaluate('document.querySelectorAll("#sections a").length'), 10); + await b.key('Escape'); + assert.equal(await b.evaluate('document.activeElement.id'), 'nav-open'); await flat(); } @@ -491,7 +492,7 @@ test('five states on Board, Inbox, Tasks, Agents and Trail', { timeout: 180000 } } // After a refusal the palette lists only the views: no kept decision or task. - const views = ['#/', '#/chat', '#/inbox', '#/messages', '#/tasks', '#/agents', '#/queue', '#/business', '#/docs', '#/settings']; + const views = ['#/', '#/projects', '#/inbox', '#/messages', '#/tasks', '#/agents', '#/queue', '#/business', '#/docs', '#/settings', '#/settings/display']; const palette = '[...document.querySelectorAll("#cmdk-list [data-href]")].map(e=>e.dataset.href)'; await b.evaluate('location.hash="#/inbox"'); await wait('document.querySelectorAll("#sections .count").length===1'); assert.match(await text('#sections'), /Inbox 1 1 blocking/); diff --git a/packages/webui/tests/w15-fixture.mjs b/packages/webui/tests/w15-fixture.mjs new file mode 100644 index 000000000..4d500957e --- /dev/null +++ b/packages/webui/tests/w15-fixture.mjs @@ -0,0 +1,62 @@ +import { mkdir, writeFile, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { fixture } from './fixture.mjs'; +import { browser } from './browser.mjs'; + +export const ids = Object.freeze({ project: '10000000-0000-4000-8000-000000000001', empty: '10000000-0000-4000-8000-000000000002', archived: '10000000-0000-4000-8000-000000000003', repository: 'fixture-repository', thread: '20000000-0000-4000-8000-000000000001', session: '30000000-0000-4000-8000-000000000001', observed: '2026-11-05T12:00:00.000Z' }); + +// All API responses are explicit, and all network destinations outside the fixture +// origin are refused. Tests may override a response by assigning window.__scenario. +export async function w15Fixture(t, { serveDisplay = false } = {}) { + const f = await fixture(); t.after(() => f.close()); + const b = await browser({ machine: false }); t.after(() => b.close()); + const errors = []; const networkErrors = []; + b.on('Network.responseReceived', ({response}) => { if (response.status >= 400) networkErrors.push({url:response.url,status:response.status}); }); + b.on('Network.loadingFailed', e => networkErrors.push({error:e.errorText})); + await b.call('Network.enable'); + const display = serveDisplay ? (await readFile(new URL('../src/public/display.js', import.meta.url))).toString('base64') : null; + b.on('Fetch.requestPaused', ({ requestId, request }) => { + const url = new URL(request.url); + if (display && url.origin === f.base && url.pathname === '/display.js') { void b.call('Fetch.fulfillRequest', { requestId, responseCode: 200, responseHeaders: [{name:'Content-Type',value:'text/javascript'}], body:display }); return; } + const allowed = url.origin === f.base; + void b.call(allowed ? 'Fetch.continueRequest' : 'Fetch.failRequest', allowed ? { requestId } : { requestId, errorReason: 'BlockedByClient' }).catch(e => errors.push(String(e))); + }); + await b.call('Fetch.enable', { patterns: [{ urlPattern: '*' }] }); + await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `(() => { + const ids = ${JSON.stringify(ids)}; window.__writes=[]; window.__unexpected=[]; window.__errors=[]; window.__scenario={}; + addEventListener('error', e => __errors.push(e.message)); addEventListener('unhandledrejection', e => __errors.push(String(e.reason))); + const data = (x,status=200) => new Response(JSON.stringify({at:ids.observed,...x}), {status,headers:{'content-type':'application/json'}}); + const real = fetch.bind(window); + window.fetch = async (input, init={}) => { + const url=new URL(typeof input==='string' ? input : input.url, location.href), path=url.pathname+url.search, method=(init.method||'GET').toUpperCase(); + if (url.origin!==location.origin) { __unexpected.push(method+' '+url.href); return new Response('refused',{status:403}); } + if (!url.pathname.startsWith('/api/')) return real(input,init); + if(method!=='GET') { __writes.push({method,path,body:init.body||null}); return data({error:'fixture-write-refused'},403); } + if (Object.hasOwn(__scenario,path)) return data(__scenario[path]); + if(path.startsWith('/api/library/status?') || path==='/api/processing' || path==='/api/bus-status') return new Response(JSON.stringify({error:'fixture-unavailable'}),{status:503,headers:{'content-type':'application/json'}}); + if(path==='/api/config') return data({send:false,edit:false,decide:false}); + if(path==='/api/business') return data({projects:[ids.repository]}); + if(path==='/api/chat/projects') return data({rows:[{id:ids.project,name:'Active project',repo:ids.repository},{id:ids.empty,name:'Empty project',repo:null},{id:ids.archived,name:'Archived project',archived:true,repo:null}]}); + if(path==='/api/chat/instances') return data({rows:[]}); + if(/^\\/api\\/chat\\/threads\\/[^/]+\\/sessions$/.test(path)) return data({rows:[{id:ids.session,name:'Default',default:true}]}); + if(/^\\/api\\/chat\\/threads\\/[^/]+\\/messages/.test(path)) return data({rows:[],after:null,hasMore:false}); + if(path==='/api/chat/threads' || /^\\/api\\/chat\\/threads\\?(shared=true|project=)/.test(path)) return data({rows:[{id:ids.thread,project:ids.project,name:'Project conversation',instance:'assistant'}]}); + if(/^\\/api\\/bus\\/(inbox|messages|agents|tasks|queue|trail)(\\?|$)/.test(path)) return data({rows:[],unread:0}); + __unexpected.push(method+' '+path); return new Response(JSON.stringify({error:'fixture-unexpected-api'}),{status:403,headers:{'content-type':'application/json'}}); + }; + })();` }); + const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,40));}throw new Error('Timed out: '+${JSON.stringify(expression)})})()`); + async function capture(state, width, route) { + if (!process.env.MOSAIC_UX_SHOTS) return; + const dir = process.env.MOSAIC_UX_SHOTS; await mkdir(dir, { recursive: true }); + await b.evaluate('document.fonts.ready'); + const prefix = `shell-${state}-${width}`; + await b.screenshot(join(dir, `${prefix}.png`)); + const metrics = await b.call('Page.getLayoutMetrics'); + const size = metrics.cssContentSize || metrics.contentSize; + const { data } = await b.call('Page.captureScreenshot', { format:'png', captureBeyondViewport:true, clip:{x:0,y:0,width:Math.ceil(size.width),height:Math.ceil(size.height),scale:1} }); + await writeFile(join(dir, `${prefix}-full.png`), Buffer.from(data,'base64')); + return {state,route,width,height:1000,observation:ids.observed,viewport:`${prefix}.png`,full:`${prefix}-full.png`,errors:[...errors,...await b.evaluate('window.__errors')],unexpected:await b.evaluate('window.__unexpected'),networkErrors:[...networkErrors]}; + } + return { f, b, wait, capture, errors }; +} diff --git a/packages/webui/tests/w15-shell-browser.test.mjs b/packages/webui/tests/w15-shell-browser.test.mjs new file mode 100644 index 000000000..09d9f4458 --- /dev/null +++ b/packages/webui/tests/w15-shell-browser.test.mjs @@ -0,0 +1,57 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { w15Fixture, ids } from './w15-fixture.mjs'; + +test('shell fixture: project navigation and narrow drawer', { timeout: 90000 }, async t => { + const { f,b,wait,capture } = await w15Fixture(t); const captures=[]; + for (const width of [1440,390]) { + await b.viewport(width,1000); await b.navigate(f.base+'/#/projects'); + await wait('document.querySelector(".chat-area h1")?.textContent==="Projects"'); + assert.match(await b.evaluate('document.querySelector("#sections").textContent'), /Projects/); + assert.equal(await b.evaluate('document.querySelector("#sections [aria-current=page]").textContent.trim()'), 'Projects'); + assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true); + captures.push(await capture('projects-list',width,'#/projects')); + if(width===390) { + await b.evaluate('document.querySelector("#nav-open").focus();document.querySelector("#nav-open").click()'); + assert.equal(await b.evaluate('document.activeElement.id'), 'nav-close'); + assert.equal(await b.evaluate('document.querySelector("#main").inert'), true); + captures.push(await capture('navigation-open',width,'#/projects')); + await b.key('Tab','Tab',8); assert.notEqual(await b.evaluate('document.activeElement.tagName'), 'BODY'); + await b.key('Escape'); assert.equal(await b.evaluate('document.activeElement.id'), 'nav-open'); + assert.equal(await b.evaluate('document.querySelector("#main").inert'), false); + } else captures.push(await capture('navigation-open',width,'#/projects')); + const sessionRoute = `#/projects/threads/${ids.thread}/sessions/${ids.session}`; + await b.evaluate(`location.hash=${JSON.stringify(sessionRoute)}`); + await wait(`document.querySelector('.chat-conversation')?.dataset.session===${JSON.stringify(ids.session)}`); + captures.push(await capture('conversation-shell',width,sessionRoute)); + await b.evaluate(`location.hash=${JSON.stringify(sessionRoute.replace('#/projects/','#/chat/'))}`); + await wait(`document.querySelector('.chat-conversation')?.dataset.session===${JSON.stringify(ids.session)}`); + assert.deepEqual(await b.evaluate('window.__writes'), []); + } + if(process.env.MOSAIC_UX_SHOTS) await writeFile(join(process.env.MOSAIC_UX_SHOTS,'manifest.json'),JSON.stringify({candidate:'uncommitted; see report for committed SHA',command:'node --test --test-concurrency=1 packages/webui/tests/w15-shell-browser.test.mjs',result:'passed',captures:captures.filter(Boolean)},null,2)+'\n'); +}); + +test('display module with simulated owner static mapping: save, failure and destination', {timeout:90000}, async t => { + const { f,b,wait } = await w15Fixture(t, { serveDisplay: true }); + await b.viewport(390,1000); await b.navigate(f.base+'/#/settings/display'); + await wait('!!document.querySelector("#display-form")'); + await b.evaluate('document.querySelector("[name=density][value=compact]").click()'); + await b.evaluate('location.hash="#/tasks"'); + await wait('document.querySelector(".display-dialog").open'); + await b.evaluate('document.querySelector("[data-choice=save]").click()'); + await wait('location.hash==="#/tasks"'); + assert.equal(await b.evaluate('JSON.parse(localStorage.getItem("mosaic-sidebar-display-v1")).density'), 'compact'); + await b.navigate(f.base+'/#/settings/display'); + await wait('!!document.querySelector("#display-form")'); + assert.equal(await b.evaluate('document.querySelector("[name=density]:checked").value'), 'compact'); + await b.evaluate('document.querySelector("[name=order][value=contact-first]").click();Storage.prototype.setItem=function(){throw new Error("blocked")};location.hash="#/inbox"'); + await wait('document.querySelector(".display-dialog").open'); + await b.evaluate('document.querySelector("[data-choice=save]").click()'); + assert.match(await b.evaluate('document.querySelector("#display-leave-status").textContent'), /local browser storage/); + assert.equal(await b.evaluate('location.hash'), '#/settings/display'); + await b.evaluate('document.querySelector("[data-choice=discard]").click()'); + await wait('location.hash==="#/inbox"'); + assert.deepEqual(await b.evaluate('window.__writes'), []); +}); From 8094901bce2d46f952786eea6469643d8dac8868 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 5 Nov 2026 21:17:18 -0600 Subject: [PATCH 10/32] fix(harness): preserve failed Claude result diagnostics (#797) --- BUILD-LOG.md | 9 ++++ adapters/README.md | 10 ++-- adapters/claude/stream.mjs | 19 ++++--- docs/SESSIONS.md | 1 + packages/harness/src/runner.mjs | 7 ++- packages/harness/tests/bundle.test.mjs | 1 + .../harness/tests/claude-session.test.mjs | 50 +++++++++++++++++-- .../tests/fixtures/claude-activity.mjs | 15 ++++-- .../harness/tests/model-activity.test.mjs | 29 ++++++++++- packages/harness/tests/runner.test.mjs | 22 ++++++++ .../plugins/harness/claude-code/index.mjs | 2 + packages/plugins/tests/harness.test.mjs | 2 + 12 files changed, 144 insertions(+), 23 deletions(-) diff --git a/BUILD-LOG.md b/BUILD-LOG.md index 1de8e9c14..7a1eee0ef 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -6202,3 +6202,12 @@ Before: isolated branch fix/mvp3-coder-stall-797 starts at 05fdfef9ea1fdddf58c72 After: Claude's private stream-json reader marks real upstream assistant events and emits only the successful final text, matching real CLI newline semantics byte for byte. Pi's existing extension also marks model text/thinking/tool deltas; mock remains a test-only immediate echo. Both reuse the existing exclusive 0600 proxy-first-output marker with fixed output\n content; init/status, synthetic CLI errors, tool results, stderr and workspace writes do not count. Runner supplies only its existing run directory; helper gets a read-only harness mount and bundle source digest. No timeout, schema, role authority, credential, proxy management or live state changed. Silent runs still stop with proxy-stall; interrupted first/resumed turns emit no partial answer or successful new session ID. Verification: Node v24.22.1 with PATH pinned to /mnt/storage/scratch/tmp/z62zsJ/node24/node_modules/node-linux-x64/bin and fresh TMPDIR from mktemp -d /mnt/storage/scratch/tmp/XXXXXX. Final full 28-file command: 376 passed, 0 failed/cancelled/skipped/todo, test-concurrency=4 (coder-stall-final-r2.tap). Includes full launcher/model-stall, harness runner/bundle, seat session/sandbox, real Claude/Pi adapter, confinement, installer/copy, source-digest and import-closure checks. Delayed tool/thinking/header/assistant fixtures use 0.8-second injected watchdog and 1.4-second pre-answer work. Initial tests retained: missing test import and failed-result exit handling, real text-mode trailing-newline difference, then a combined-gate grandchild-reaping assertion race; all corrected and full final gate green. Whitespace and baseline log-prefix preservation checks pass. Report, exact commands, SHA256 manifest, scans and all TAP receipts: /mnt/storage/scratch/mosaic-lead/mvp3/coder-stall-fix-r1-report.md and coder-stall-* siblings. Local commit follows these green suites. Independent exact-commit review, CI/integration and deployment/live GLM acceptance remain with the lead; no push, stash, fleet operation or external message. + + +## 2026-11-05 — MVP 3 coder-stall review F1 correction (#797), local implementation handoff + +Before: candidate 1bd0077391f1925db5e66796b98540e48f13bfc2 discarded complete error-typed result strings, losing text-mode stdout such as API Error: 400 review-local-api-error with exit 1. Restoring that output also required preventing the runner's generic stdout fallback from mistaking synthetic errors for model activity. Explicit correction charter authorizes one local follow-up commit on fix/mvp3-coder-stall-797; the linked-worktree queue refusal was diagnosed and left intact. + +After: the private stream reader renders every complete final result string independently of success/failure, retains CLI nonzero exit codes, fails error-typed zero-exit turns, and withholds result text on stream-integrity/helper marker failures. The adapter's existing success-only session persistence remains unchanged. The Claude plugin declares adapter-owned activity in its existing bundle environment; the runner reads module metadata, ignores ambient declarations and keeps stdout fallback for Pi/worker/mock paths. No core harness-name conditional, watchdog timing/default, broker, roles, contracts, policy or live state change. Bundle tests pin the helper SHA-256 to 9c2cc28b7e719c1d49913401fffc952759d8633e50ae39ca36bfc50729b9415b. + +Verification: before product edits, whole-file negative controls on 1bd007739 passed 68 and failed 7; the real installed Claude CLI HTTP 400 test failed on empty streaming stdout versus 38 text-mode bytes. It now passes with identical stdout bytes and exit 1, no activity marker and no new session ID, using only localhost, isolated HOME/configuration and a dummy key. Added subtype/is_error/nonzero controls, corrupt-after-result/oversized/truncated/helper marker-failure cases and real runner fallback controls. Final frozen gate: 414 passed, 0 failed/skipped/cancelled/todo across 31 whole files, one node --test invocation per file, at most two concurrently. Node v24.22.1 on the requested PATH; set -o pipefail and fresh scratch TMPDIR. The initial broad gate's sole architecture failure (412/413) is retained honestly; moved the declaration into the plugin and reran all 31 files green. Whitespace, source hashes and append-only prefix checks pass. Report: /mnt/storage/scratch/mosaic-lead/mvp3/coder-stall-fix-r2-report.md; per-file TAPs, exact commands, negative controls, source/pin scans and hashes: coder-stall-fix-r2-evidence/. Local commit follows verification; independent exact-commit review remains with the lead. No push, stash, forge write, real model call, contact with 10.1.1.53, deployment or live recovery. diff --git a/adapters/README.md b/adapters/README.md index 4bda20b31..e60a3597f 100644 --- a/adapters/README.md +++ b/adapters/README.md @@ -77,6 +77,7 @@ session's workspace and its own process group. Same contract, plus: | `MOSAIC_TOOL_SOCKET` | The runner's tool socket, which the typed tools call. | | `MOSAIC_TURN_MARKER` | `pi`: the extension writes it at `agent_end`; a pi turn that exits 0 without it failed. | | `MOSAIC_MODEL_ACTIVITY_DIR` | Managed runner's existing writable run directory. Trusted model streaming events publish the fixed `output\n` token to `proxy-first-output` (0600, exclusive create). No event contents or credentials enter it. Claude's private stream reader and Pi's extension signal thinking, tool-use and text activity before final stdout; init/status, tool results and workspace writes do not count. | +| `MOSAIC_MODEL_ACTIVITY_SOURCE` | The harness module may set `adapter` in its bundle environment to declare model events authoritative and disable the runner's stdout activity fallback. Claude declares this because final stdout can be a synthetic error. Pi and mock retain the fallback. The runner reads the declaration from module metadata, ignoring any ambient environment copy. | | `MOSAIC_EXTENSIONS` | `pi` only: extension files loaded with `-e`; a missing one exits 2. The pi module names two: the gate and typed tools extension, then the unslop check's `tools/unslop-hook/extension.ts` (row 66). | | `MOSAIC_UNSLOP_LOG` | The unslop check's JSONL log, `/unslop.jsonl`, which the runner sets for both adapters and reads after each turn (row 66). | | `MOSAIC_CLAUDE_SETTINGS`, `MOSAIC_CLAUDE_MCP_CONFIG` | `claude` only: the bundle's gate hook and MCP server; required. | @@ -87,9 +88,12 @@ The layers each adapter relies on, and what they don't cover, are in `packages/harness/README.md`. Claude uses `--output-format stream-json --verbose --include-partial-messages`. -The adapter's `stream.mjs` consumes those events and prints only the successful -final result, with the same newline as the CLI's text format. Malformed or -incomplete streams fail the turn; a failed or interrupted first turn still +The adapter's `stream.mjs` consumes those events and prints a complete final +result, including error results, with the same newline as the CLI's text format. +CLI failures retain their nonzero exit status. Claude's model events alone mark +activity; its final stdout may be a synthetic error. The runner retains stdout +activity marking for Pi and mock adapters. Malformed or incomplete streams emit +only the fixed diagnostic and fail the turn; a failed or interrupted first turn does not persist a reusable session ID. The helper is mounted read-only and its digest is recorded in the launch bundle. Pi retains print mode and signals activity through its existing extension. Mock remains a test-only echo. diff --git a/adapters/claude/stream.mjs b/adapters/claude/stream.mjs index 30a06f606..f19d3623d 100644 --- a/adapters/claude/stream.mjs +++ b/adapters/claude/stream.mjs @@ -1,5 +1,5 @@ -// Consume Claude Code's SDK event stream privately. Only the successful result -// reaches stdout, ensuring a trailing newline like --output-format text. +// Consume Claude Code's SDK event stream privately. A complete final result +// reaches stdout, including errors, with the same newline as text mode. import { spawn } from 'node:child_process'; import { firstOutputMarker } from '../../packages/harness/src/first-output.mjs'; @@ -37,10 +37,8 @@ function event(line) { resultSeen = true; // Let a normal CLI failure exit on its own, retaining its exit code. resultFailed = item.subtype !== 'success' || !!item.is_error; - if (!resultFailed) { - if (typeof item.result !== 'string') throw Error(); - answer = item.result; - } + if (typeof item.result === 'string') answer = item.result; + else if (!resultFailed) throw Error(); } } child.stdout.setEncoding('utf8'); @@ -63,10 +61,11 @@ child.on('close', (code, signal) => { if (!failed && pending) { try { event(pending); } catch { fail(); } } - if (failed || resultFailed || code !== 0 || signal || answer === undefined) { - if (!failed && code === 0 && !signal) fail(); - process.exitCode = code || 2; - } else { + // Integrity and rendering are separate from the CLI's success classification. + // Buffer until close so corrupt/truncated streams never leak a final result. + if (!failed && (!resultSeen || signal)) fail(); + if (!failed && answer !== undefined) { process.stdout.write(answer.endsWith('\n') ? answer : answer + '\n'); } + process.exitCode = code || (failed || resultFailed ? 2 : 0); }); diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index 84c45f841..c9dc78df7 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -2066,3 +2066,4 @@ are never rewritten or removed; corrections are new entries. 2026-11-04 | Mos, skill integrator | #818 / Gitea #1845 | Integrated exact independently approved ms-ux-improvement candidate02 into source;3hashes/validator/whitespace pass and author histories preserved. Separate commit from MVP; its activation target staysbd8120318. No install, activation, publication or live action. 2026-11-05 | Codex implementation child | MVP 3 #797 D2 coder-stall fix on fix/mvp3-coder-stall-797 from 05fdfef9e | Claude private streaming activity marker + answer-only stdout; Pi model-event coverage; silent/interrupted controls preserved. Final Node 24.22.1 full 28-file gate: 376 pass, 0 fail/skip/cancel. Queue refused linked worktree; no workaround. Local commit only, no push/stash/live actions. Report: /mnt/storage/scratch/mosaic-lead/mvp3/coder-stall-fix-r1-report.md; independent review remains with lead. +2026-11-05 | Implementation child | MVP 3 #797 coder-stall review F1 on fix/mvp3-coder-stall-797 from 1bd007739 | Preserve complete failed Claude result stdout and exit status; module-declared model activity disables synthetic stdout marking while Pi/worker/mock fallback remains. Real localhost HTTP 400 regression fails baseline and passes fix; final Node 24.22.1 gate 414/414 across 31 whole files, no skips. One local commit only; no push/stash/forge/live actions. Queue refused linked worktree without workaround. Report: /mnt/storage/scratch/mosaic-lead/mvp3/coder-stall-fix-r2-report.md; independent review remains with lead. diff --git a/packages/harness/src/runner.mjs b/packages/harness/src/runner.mjs index c827e2cf4..5fddf5023 100644 --- a/packages/harness/src/runner.mjs +++ b/packages/harness/src/runner.mjs @@ -345,7 +345,12 @@ function runTurn({ session, request, n, env, onChild }) { closeSync(errFd); onChild(child); let out = ""; - const markOutput = firstOutputMarker(session.runDir); + // The harness module may declare its adapter's model events authoritative. + // Such adapters can print synthetic error results, which aren't activity. + // Read the declaration from module metadata, never the ambient environment; + // adapters without it retain the stdout fallback (including worker paths). + const markOutput = session.harnessEnv.MOSAIC_MODEL_ACTIVITY_SOURCE === 'adapter' + ? () => {} : firstOutputMarker(session.runDir); child.stdout.setEncoding("utf8"); child.stdout.on("data", (s) => { markOutput(s); diff --git a/packages/harness/tests/bundle.test.mjs b/packages/harness/tests/bundle.test.mjs index af2967406..7cdf1455c 100644 --- a/packages/harness/tests/bundle.test.mjs +++ b/packages/harness/tests/bundle.test.mjs @@ -152,6 +152,7 @@ test("a claude-code bundle adds the wrapped gate hook and the MCP config", (t) = assert.ok(m.typed.every((n) => n.startsWith("mcp__mosaic__"))); assert.equal(m.sources.claudeStream.path, join(REPO, "adapters/claude/stream.mjs")); assert.equal(m.sources.claudeStream.sha256, sha(m.sources.claudeStream.path)); + assert.equal(m.sources.claudeStream.sha256, "9c2cc28b7e719c1d49913401fffc952759d8633e50ae39ca36bfc50729b9415b"); assert.deepEqual(Object.keys(m.sources).sort(), ["claudeGate", "claudeStream", "gate", "mcpServer", "tools", "unslopCheck", "unslopHook", "unslopLists"]); // Row 66 (#1559): the unslop hooks run after a reply and before the next prompt, and never block. for (const [event, which] of [["Stop", "stop"], ["UserPromptSubmit", "prompt"]]) { diff --git a/packages/harness/tests/claude-session.test.mjs b/packages/harness/tests/claude-session.test.mjs index 8b24f1c5c..a8c8faac9 100644 --- a/packages/harness/tests/claude-session.test.mjs +++ b/packages/harness/tests/claude-session.test.mjs @@ -6,6 +6,7 @@ import { fixtureCacheEnv } from '../../../scripts/testing/lane-fixtures.mjs'; // except the argv check, which uses a stand-in claude. import { test } from "node:test"; +import { createServer as createHttp } from "node:http"; import assert from "node:assert/strict"; import { spawn, spawnSync } from "node:child_process"; import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; @@ -66,14 +67,15 @@ async function session(t, script, tools = ["read", "bash"], done = (r) => `ANSWE function turn(env, request, cwd, adapter = ADAPTER) { return new Promise((resolve) => { const child = spawn("/bin/sh", [adapter], { cwd, env: { ...env, ...fixtureCacheEnv(env.CLAUDE_CONFIG_DIR), MOSAIC_REQUEST: request }, stdio: ["ignore", "pipe", "pipe"], detached: true }); - let stdout = ""; + const chunks = []; let stderr = ""; - child.stdout.on("data", (b) => (stdout += b)); + child.stdout.on("data", (b) => chunks.push(b)); child.stderr.on("data", (b) => (stderr += b)); const clock = setTimeout(() => process.kill(-child.pid, "SIGKILL"), 120_000); child.on("close", (code) => { clearTimeout(clock); - resolve({ code, stdout, stderr }); + const stdoutBytes = Buffer.concat(chunks); + resolve({ code, stdout: stdoutBytes.toString("utf8"), stdoutBytes, stderr }); }); }); } @@ -158,6 +160,48 @@ test(`claude: streaming preserves the real CLI text answer byte for byte (${JSON }); } +test("claude: HTTP 400 keeps real text-mode stdout and exit status without model activity or a new session", { skip }, async t => { + const { dir, workspace, env } = await session(t, [], []); + let requests = 0; + const api = createHttp((req, res) => { + req.resume(); + req.on('end', () => { + if (!/\/messages(\?|$)/.test(req.url)) { + res.writeHead(200, { 'content-type': 'application/json' }); + return res.end(JSON.stringify({ input_tokens: 1 })); + } + requests++; + res.writeHead(400, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ type: 'error', error: { type: 'invalid_request_error', message: 'review-local-api-error' } })); + }); + }); + await new Promise(resolve => api.listen(0, '127.0.0.1', resolve)); + t.after(() => api.close()); + const textAdapter = join(dir, 'text-adapter.sh'); + writeFileSync(textAdapter, readFileSync(ADAPTER, 'utf8') + .replace('node "$STREAM" "$CLAUDE"', '"$CLAUDE"') + .replace('--output-format stream-json --verbose --include-partial-messages', '--output-format text')); + const results = []; + for (const [name, adapter] of [['text', textAdapter], ['stream', ADAPTER]]) { + const runDir = join(dir, name); + mkdirSync(runDir); + const result = await turn({ ...env, + HOME: join(runDir, 'home'), CLAUDE_CONFIG_DIR: join(runDir, 'config'), + ANTHROPIC_BASE_URL: `http://127.0.0.1:${api.address().port}`, + MOSAIC_SESSION_DIR: join(runDir, 'session'), MOSAIC_MODEL_ACTIVITY_DIR: runDir, + }, 'answer', workspace, adapter); + results.push(result); + assert.equal(result.code, 1, `${name}: ${result.stderr}`); + assert.equal(existsSync(join(runDir, 'session/claude-session-id')), false, name); + assert.equal(existsSync(join(runDir, 'proxy-first-output')), false, name); + } + const [text, stream] = results; + assert.ok(requests >= 2, 'both real CLI invocations reached only the local scripted API'); + assert.deepEqual(text.stdoutBytes, Buffer.from('API Error: 400 review-local-api-error\n')); + assert.deepEqual(stream.stdoutBytes, text.stdoutBytes); + assert.equal(stream.code, text.code); +}); + // --restricted is what keeps CLAUDE.md files and auto-memory out of the // session's prompt (README "Limits"). This one runs without claude: a // stand-in records the adapter's argv. diff --git a/packages/harness/tests/fixtures/claude-activity.mjs b/packages/harness/tests/fixtures/claude-activity.mjs index 7b20928e3..3ee68ca98 100644 --- a/packages/harness/tests/fixtures/claude-activity.mjs +++ b/packages/harness/tests/fixtures/claude-activity.mjs @@ -2,8 +2,9 @@ import { writeFileSync } from 'node:fs'; import { join } from 'node:path'; const args = process.argv.slice(2); -const answer = ' Answer é 🦆\nsecond line\n'; const mode = process.env.FAKE_CLAUDE_MODE ?? 'tool'; +const answer = mode === 'failed' || mode === 'failed-success-subtype' || mode === 'failed-zero-exit' + ? 'API Error: 400 fixture-error' : ' Answer é 🦆\nsecond line\n'; writeFileSync(join(process.env.MOSAIC_WORKSPACE, 'cli-pid'), String(process.pid)); const emit = item => process.stdout.write(JSON.stringify(item) + '\n'); if (args[args.indexOf('--output-format') + 1] === 'text') { @@ -20,9 +21,15 @@ if (args[args.indexOf('--output-format') + 1] === 'text') { process.stdout.write('invalid credential-canary\n'); } else if (mode === 'missing') { // Exit 0 without a result still fails the adapter. - } else if (mode === 'failed') { - emit({ type: 'result', subtype: 'error_during_execution', is_error: true, errors: ['credential-canary'] }); - process.exitCode = 7; + } else if (['failed', 'failed-success-subtype', 'failed-zero-exit', 'nonzero-success'].includes(mode)) { + emit({ type: 'result', subtype: mode === 'failed-success-subtype' || mode === 'nonzero-success' ? 'success' : 'error_during_execution', + is_error: mode === 'failed-success-subtype', result: answer, errors: ['credential-canary'] }); + process.exitCode = mode === 'failed-zero-exit' ? 0 : 7; + } else if (['malformed-after-result', 'oversized', 'truncated'].includes(mode)) { + // A complete answer before corrupt trailing data must still be withheld. + emit({ type: 'result', subtype: 'success', result: answer }); + process.stdout.write(mode === 'oversized' ? 'x'.repeat(16 * 1024 * 1024 + 1) + : mode === 'truncated' ? '{"type":"result"' : 'invalid credential-canary\n'); } else { if (mode !== 'silent') { const event = mode === 'thinking' diff --git a/packages/harness/tests/model-activity.test.mjs b/packages/harness/tests/model-activity.test.mjs index 96812d558..23a5fcb5d 100644 --- a/packages/harness/tests/model-activity.test.mjs +++ b/packages/harness/tests/model-activity.test.mjs @@ -118,17 +118,42 @@ test(`interrupted Claude activity emits no partial answer, preserves session sta }); } -for (const mode of ['malformed', 'missing', 'failed']) { +for (const mode of ['malformed', 'missing', 'malformed-after-result', 'oversized', 'truncated']) { test(`Claude ${mode} stream fails closed without raw event diagnostics or a session receipt`, async t => { const f = fixture(t, mode), result = await start(t, f).done; assert.notEqual(result.code, 0); - if (mode === 'failed') assert.equal(result.code, 7, 'preserve the CLI exit code'); assert.equal(result.stdout.length, 0); + assert.match(result.stderr, /claude adapter: invalid or incomplete model stream/); assert.doesNotMatch(result.stderr, /credential-canary/); assert.equal(existsSync(join(f.runDir, 'session/claude-session-id')), false); }); } +test('Claude helper marker failure keeps the fixed diagnostic and emits no result', async t => { + const f = fixture(t); + // A file where the marker directory should be makes the private helper fail. + f.env.MOSAIC_MODEL_ACTIVITY_DIR = join(f.runDir, 'prompt'); + const result = await start(t, f).done; + assert.notEqual(result.code, 0); + assert.equal(result.stdout.length, 0); + assert.match(result.stderr, /claude adapter: invalid or incomplete model stream/); + assert.doesNotMatch(result.stderr, /credential-canary|ENOTDIR/); + assert.equal(existsSync(join(f.runDir, 'session/claude-session-id')), false); + assert.equal(existsSync(join(f.runDir, 'proxy-first-output')), false); +}); + +for (const mode of ['failed', 'failed-success-subtype', 'failed-zero-exit', 'nonzero-success']) { + test(`Claude ${mode} renders the final result independently of failure without a session or synthetic activity`, async t => { + const f = fixture(t, mode), result = await start(t, f).done; + assert.equal(result.code, mode === 'failed-zero-exit' ? 2 : 7); + const answer = spawnSync(f.cli, ['--output-format', 'text'], { env: f.env }); + assert.deepEqual(result.stdout, answer.stdout); + assert.doesNotMatch(result.stderr, /invalid or incomplete model stream/); + assert.equal(existsSync(join(f.runDir, 'session/claude-session-id')), false); + assert.equal(existsSync(join(f.runDir, 'proxy-first-output')), false); + }); +} + test('Pi extension marks real streamed thinking/tools/text, never local activity or user/tool messages', async t => { const runDir = scratch(t); writeFileSync(join(runDir, 'policy'), JSON.stringify({ harness: 'pi', tools: [], typed: [] })); diff --git a/packages/harness/tests/runner.test.mjs b/packages/harness/tests/runner.test.mjs index 0792431b6..795e81488 100644 --- a/packages/harness/tests/runner.test.mjs +++ b/packages/harness/tests/runner.test.mjs @@ -228,6 +228,28 @@ test("a message becomes a turn, the answer goes back as a RESULT, SIGTERM releas assert.ok(!existsSync(join(ctx.runDir, "tools.sock"))); }); +// Claude's private stream reader owns model activity; final result stdout alone +// (even a synthetic success) must not cancel the model watchdog. Other adapters +// still need the stdout fallback when no streaming producer is installed. +for (const [harness, exit, activity] of [['claude-code', 1, false], ['claude-code', 0, false], ['pi', 0, true], ['mock', 0, true]]) { + test(`stdout activity fallback: ${harness}, adapter exit ${exit}`, async t => { + const ctx = await setup(t, { session: { harness, turnEnd: 'exit', + harnessEnv: activity ? {} : { MOSAIC_MODEL_ACTIVITY_SOURCE: 'adapter' }, + } }); + ctx.inbox = []; + writeFileSync(join(ctx.dir, 'adapter.sh'), `printf 'API Error: 400 synthetic-result\\n'\nexit ${exit}\n`); + // An ambient declaration cannot suppress the fallback for other adapters. + const r = startRunner(ctx.runDir, ctx.cap, { MOSAIC_MODEL_ACTIVITY_SOURCE: 'adapter' }); + t.after(async () => { r.child.kill('SIGTERM'); await r.exited; }); + const reply = await ask(ctx); + if (exit) assert.match(reply.body, /The turn failed: adapter exited 1/); + else assert.equal(reply.body, 'API Error: 400 synthetic-result'); + const marker = join(ctx.runDir, 'proxy-first-output'); + assert.equal(existsSync(marker), activity); + if (activity) assert.equal(readFileSync(marker, 'utf8'), 'output\n'); + }); +} + // Row 109 (#1603): each turn reads the state when it starts. The fake adapter echoes the request, // so a reply shows the block the turn was given. const ask = async (ctx, body = "what now") => { diff --git a/packages/plugins/harness/claude-code/index.mjs b/packages/plugins/harness/claude-code/index.mjs index 9f6367f85..a14ebfb12 100644 --- a/packages/plugins/harness/claude-code/index.mjs +++ b/packages/plugins/harness/claude-code/index.mjs @@ -124,6 +124,8 @@ export function env({ files, policy, session, proxy = null }) { MOSAIC_CLAUDE_SETTINGS: files.settings, MOSAIC_CLAUDE_MCP_CONFIG: files.mcp, MOSAIC_THINKING: session.thinking, + // stream.mjs marks upstream model events; final stdout may be an API error. + MOSAIC_MODEL_ACTIVITY_SOURCE: "adapter", ...(proxy ? { ANTHROPIC_BASE_URL: proxy.baseUrl, ANTHROPIC_AUTH_TOKEN: proxy.key } : {}), }; } diff --git a/packages/plugins/tests/harness.test.mjs b/packages/plugins/tests/harness.test.mjs index 5ff4bb558..5a781c6ee 100644 --- a/packages/plugins/tests/harness.test.mjs +++ b/packages/plugins/tests/harness.test.mjs @@ -111,6 +111,7 @@ test("row 65 env passes the thinking level to both adapters, and claude-code's b const session = { provider: "fixture", model: "m", thinking: "high", toolSocket: "/run/tools.sock" }; const piEnv = pi.env({ policy: { tools: ["read"] }, tools: [{ name: "list_tasks" }], session }); assert.deepEqual([piEnv.MOSAIC_THINKING, piEnv.PI_MODEL, piEnv.MOSAIC_TOOLS], ["high", "m", "read,list_tasks"]); + assert.equal(piEnv.MOSAIC_MODEL_ACTIVITY_SOURCE, undefined, "Pi retains stdout fallback for paths without streaming activity"); // Row 66: pi loads the unslop check as its second extension. assert.deepEqual(piEnv.MOSAIC_EXTENSIONS.split(","), [join(REPO, "packages", "harness", "src", "pi-extension.mjs"), join(REPO, "tools", "unslop-hook", "extension.ts")]); const dir = scratch(t); @@ -127,6 +128,7 @@ test("row 65 env passes the thinking level to both adapters, and claude-code's b assert.match(settings.hooks.UserPromptSubmit[0].hooks[0].command, /^timeout -k 2 10 .*claude-hook\.js' prompt; exit 0$/); assert.equal(JSON.parse(readFileSync(join(dir, "mcp.json"), "utf8")).mcpServers.mosaic.args.at(-1), "/run/tools.sock"); const claudeEnv = claude.env({ files: out.files, policy: { tools: ["read"], typed: [] }, session }); + assert.equal(claudeEnv.MOSAIC_MODEL_ACTIVITY_SOURCE, "adapter"); assert.deepEqual([claudeEnv.MOSAIC_THINKING, claudeEnv.MOSAIC_CLAUDE_SETTINGS, claudeEnv.MOSAIC_CLAUDE_MCP_CONFIG], ["high", out.files.settings, out.files.mcp]); }); From 6e5cf26e7a079a50d26fe9a671690d50dc006037 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 5 Nov 2026 21:18:26 -0600 Subject: [PATCH 11/32] fix(bus): quote approved requirement texts into coder/reviewer continuations (#797) D3: coder/reviewer sandboxes cannot read the product records repo and list_requirements stays PM/CTO-only (row 90, #1584). The bus now quotes the task-cited approved requirement texts (exact text, id, title, citing task refs, snapshot id, product key, records commit) into the implementation and review continuations, resolved host-side through the same catalog resolver behind the scoped requirements verb, never from model input. Quotes are bounded at 16384 bytes and framed as quoted data; an unresolvable or revoked snapshot degrades to an ask-PM line without blocking the task. No role, tool or schema widened; one duty note each in roles/coder.md and roles/reviewer.md. --- BUILD-LOG.md | 6 + packages/bus/src/broker.mjs | 58 ++++++++ packages/bus/src/request-continuation.mjs | 30 +++- packages/bus/src/request-lifecycle.mjs | 20 ++- .../bus/tests/request-continuation.test.mjs | 133 ++++++++++++++++++ roles/coder.md | 3 + roles/reviewer.md | 4 + 7 files changed, 244 insertions(+), 10 deletions(-) diff --git a/BUILD-LOG.md b/BUILD-LOG.md index 6a74a7337..af59d43b9 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -6201,3 +6201,9 @@ Before: pinned 05fdfef9ea1fdddf58c725c04f229ca8559fc054; live b1869a13 has five ## 2026-11-05 — MVP 3 planning coverage implementation ready for review (#797) After: preserve the scalar task requirement schema and fail-closed coverage gate; incomplete advance now sends a deduplicated PM bus notice naming every uncovered id and explaining correction, task-limit reconciliation and why a push cannot clear coverage. PM contract, task_create description and planning continuation state the same rule. Five-requirement regression proves prose/push do not cover missing ids, retries do not flood, changed coverage updates the notice, and complete assigned coverage advances without pushes for the other tasks. Focused whole files: 52 pass/0 fail/0 skip; scoped/lifecycle/import/role regressions: 115 pass/0 fail/0 skip; dependency/path/closure files: 61 pass/0 fail/0 skip (228 total). Initial fixture run: 51 pass/1 fail (new test used an invalid PRD author; corrected to pm.pm). Initial path checks: 28 pass/23 fail from absent worktree node_modules; canonical dependency versions matched package.json, linked locally, then all 61 passed. Read-only .53 evidence identifies proxy-stall watchdog as coder stop cause; D2–D5 remain report-only follow-ups. Detailed commands, receipts, limitations and exact commit: /mnt/storage/scratch/mosaic-lead/mvp3/planning-gate-fix-r1-report.md. No push, merge, runtime write, role authority expansion or product-acceptance claim. + +## 2026-11-05 — MVP 3 D3 requirement texts in continuations implemented (#797) + +Before: branch fix/mvp3-req-texts-797 off 62bece4a9da803d0734b435d157578ea5c8d2001 (planning-gate fix) in the T3 worktree; coder/reviewer could not read the product's approved requirement records (D3), and list_requirements remains PM/CTO-only by row 90 (#1584), asserted unchanged by packages/harness/tests/tools.test.mjs. Scope chosen by the lead: no role/tool widening; the bus itself quotes the task-cited approved texts into the implementation/review continuations it already sends. + +After: broker #continuationQuote resolves, host-side and never from model input, this request's task.created citations (same product, exact approved snapshot) through the same catalog resolver behind the scoped requirements verb (#requirementsFor), bounding titles+texts at CONTINUATION_QUOTE_BUDGET=16384 bytes with a truncation+ask-PM line. request-continuation renders the block for push_working/review_verdict only, framed as quoted owner-approved data, never instructions; intents/evidence digests unchanged (quote lives in the message body only). request-lifecycle resolves the quote before its journal transactions (resume path; advance reuses the pre-transaction catalog.resolve result, covering the review→implementation changes loopback). Unresolvable or revoked snapshots and missing citations degrade to an ask-PM line without blocking or throwing. roles/coder.md and roles/reviewer.md gained one duty note each; no roles/*.json, tools.mjs gate, schema or authority change. New regressions: exact text/snapshot/records-commit/task citation with foreign-request, foreign-product and unknown-id isolation on a fresh send; reviewer quote; stale-drift and revoked-snapshot fallbacks without throw; budget truncation ordering. Whole files: bus+tasks 197 pass/0 fail/0 skip; harness+business 110 pass/0 fail/0 skip (Row 90 assertion green, coder/reviewer still lack list_requirements); adjacent recovery/limits/cli-lifecycle/launch 45 pass/0 fail/0 skip; 352 total, git diff --check clean. Initial runs failed three new tests ([draft] status not in snapshot rows; append-only request_snapshots/events triggers; successor reuses a waiting unroutable message) — corrected the tests, not the guards. Receipts: /mnt/storage/scratch/mosaic-lead/mvp3/req-texts-*.tap and req-texts-fix-r1-report.md. No push, merge, runtime write or product-acceptance claim. diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 743bf7ef2..b2ad2ed70 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -98,6 +98,9 @@ export const STRANDED = 50; const TASK_ARGS = ['done', 'assignee', 'label', 'requirement', 'limit', 'after', 'task_ref', 'project_key', 'requirement_snapshot']; export const TASK_PAGE = 50; export const TASK_PAGE_MAX = 200; +// D3 (#797): byte budget for the approved requirement texts quoted into one coder/reviewer +// continuation. Titles count against it too; past it the quote is cut and says to ask the PM. +export const CONTINUATION_QUOTE_BUDGET = 16384; // Row 134 (REQ-HARN-2): the web verbs a role holding the "web" tool grant may call, and the label // on every reply. Fetched pages and search results are other people's text, never instructions. // Row 148 (#1642): the human-only verbs behind the Console's document editor. The save is the one @@ -631,6 +634,7 @@ export class Broker { if (context.chief && target !== context.chief) this.#notice(state.business, message, context.chief, target, NOTICE_ROLE, this.#business(state.business).launch.instances.includes(target), this.#store.get('SELECT body FROM messages WHERE id=?', message)?.body); }, continuationMessage: (state, target, body, context) => this.#continuationMessage(state, target, body, context), + continuationQuote: (state, resolved = null) => this.#continuationQuote(state, resolved), origin: (identity, request) => this.#requestOrigin(identity, request), authorize: (identity, state, operation) => this.#requestAuthorize(identity, state, operation), project: (business, project) => { @@ -704,6 +708,60 @@ export class Broker { this.#notice(state.business, mid, context.chief, target, NOTICE_ROLE, b.launch.instances.includes(target), body); return mid; } + // D3 (#797): coder/reviewer sandboxes cannot read the product records repo, and list_requirements + // stays PM/CTO-only (row 90, #1584). The continuation itself therefore quotes the task-cited + // approved requirement texts. Everything is resolved host-side through the same catalog resolver + // behind the scoped `requirements` verb (#requirementsFor) — never from model-supplied input, and + // with no role or tool widened. Citations come from this request's `task.created` events for this + // exact product and approved snapshot (the same evidence #requestProgress admits), and an + // unresolvable snapshot degrades to { unavailable } so the message can say "ask the PM" without + // blocking the task. + async #continuationQuote(state, resolved = null) { + const unavailable = { unavailable: true }; + if (!state || !state.business || !state.request || !state.project || !state.snapshot) return null; + try { + const cited = new Map(); + for (const row of this.#store.all("SELECT subject,body FROM events WHERE business=? AND kind='task.created'", state.business)) { + let body; try { body = JSON.parse(row.body); } catch { continue; } + if (body?.request !== state.request || body?.project_key !== state.project || + body?.requirement_snapshot !== state.snapshot || typeof body.requirement !== 'string') continue; + const refs = cited.get(body.requirement) ?? new Set(); + refs.add(row.subject); + cited.set(body.requirement, refs); + } + if (!cited.size) return unavailable; + const catalog = this.#requests?.catalog ?? this.#catalog; + if (!catalog) return unavailable; + const snapshot = resolved ?? await catalog.resolve({ business: state.business, project: state.project, snapshot: state.snapshot }); + if (!snapshot || snapshot.id !== state.snapshot || !Array.isArray(snapshot.requirements)) return unavailable; + const cut = (value, limit) => { + const bytes = Buffer.from(value, 'utf8'); + if (bytes.length <= limit) return value; + let end = limit; + while (end > 0 && (bytes[end] & 0xc0) === 0x80) end--; + return bytes.subarray(0, end).toString('utf8'); + }; + const requirements = []; + let budget = CONTINUATION_QUOTE_BUDGET, truncated = false; + for (const [rid, refs] of [...cited].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) { + const record = snapshot.requirements.find(r => r?.id === rid); + if (!record) continue; // not in the approved snapshot: never quote it + let title = typeof record.title === 'string' ? record.title : ''; + let text = typeof record.text === 'string' ? record.text : ''; + let left = budget - Buffer.byteLength(title); + if (left < 0) { title = cut(title, budget); left = 0; } + if (Buffer.byteLength(text) > left) { truncated = true; text = cut(text, left); } + budget -= Buffer.byteLength(title) + Buffer.byteLength(text); + requirements.push({ id: record.id, title, text, status: record.status ?? null, tasks: [...refs].sort() }); + } + if (!requirements.length) return unavailable; + return { snapshot: snapshot.id, project: state.project, records_commit: snapshot.records_commit ?? null, truncated, requirements }; + } catch { + // Fail closed on any refusal (revoked or stale snapshot, unreadable records): the + // continuation still goes out, without texts, telling the agent to ask the PM. + return unavailable; + } + } // Host-only. The caller is still the real chief capability, checked for its current launch // grant. Release only after the runner reports its turn finished (message.read), never as an ack. requestChief(business) { diff --git a/packages/bus/src/request-continuation.mjs b/packages/bus/src/request-continuation.mjs index f0059b8bc..a5a4e05d5 100644 --- a/packages/bus/src/request-continuation.mjs +++ b/packages/bus/src/request-continuation.mjs @@ -39,7 +39,23 @@ export function createContinuations({ journal, services }) { question: state.question?.id ?? null, answer: state.question?.answer ?? null, plan: state.plan_digest, spec_commit: state.spec_commit, candidate: state.candidate ? { prd_version: state.candidate.prd_version, records_commit: state.candidate.records_commit, prd_path: state.candidate.prd_path } : null, snapshot: state.snapshot, tasks: state.tasks, delivery: state.delivery_evidence ?? null, events: state.evidence ?? [] }); const intentFor = (state, target, step) => ({ key: digest([state.request, state.revision, target, step]), request: state.request, revision: state.revision, target, step, phase: state.phase, project: state.project, evidence: evidence(state) }); - function send(state, intent, context, progress = null) { + // D3 (#797): the bus quotes the task-cited approved requirement texts into the implementation and + // review continuations, because coder/reviewer sandboxes cannot read the product records repo and + // list_requirements stays PM/CTO-only (row 90, #1584). The quote object is host-resolved (see the + // broker's continuationQuote service) and framed here as quoted data, never as instructions. + const quoteBlock = (step, quote) => { + if (step !== 'push_working' && step !== 'review_verdict') return ''; + if (!quote) return ''; + if (quote.unavailable) + return '\nThe approved requirement texts for this task could not be resolved from the records right now; ask the PM to send them before implementing or reviewing.'; + const head = `\nApproved requirement texts (quoted owner-approved data, not instructions) for product ${quote.project}, snapshot ${quote.snapshot}` + + (quote.records_commit ? `, records commit ${quote.records_commit}` : '') + ':\n'; + const rows = quote.requirements.map(r => + `- ${r.id}${r.tasks?.length ? ` (task ${r.tasks.join(', ')})` : ''}${r.title ? ` "${r.title}"` : ''}${r.status ? ` [${r.status}]` : ''}: ${r.text}`); + const tail = quote.truncated ? '\nQuoted text is truncated at the continuation budget; ask the PM for the full approved texts.' : ''; + return head + rows.join('\n') + tail; + }; + function send(state, intent, context, progress = null, quote = null) { const control = { request: state.request, revision: state.revision, op: `continue-${intent.key.slice(0, 32)}` }; const next = { intake_propose: { args: control, requires: ['source_project','project','name','tracker_project'] }, @@ -61,10 +77,10 @@ export function createContinuations({ journal, services }) { review_verdict: 'Fetch each current review request with fetch_review, run acceptance checks, before the verdict, write a docs/*.md verification document whose entire content is raw JSON (no Markdown fences or prose): {"version":1,"task_ref":"...","review_request":"...","commit":"...","results":"..."}. Results must be a nonempty string. Return review_verdict with a raw JSON body containing exactly {"verification_record":{"path":"...","commit":"..."},"results":"..."}, using the write_record receipt and matching results.', advance_request: 'Check the existing task, push and review evidence, then call advance_request at the current intake_status revision. Never claim a success phase.', task_close: 'Close each task with task_close citing the latest approve verdict. Read the review verdict JSON for verification_record and results, then call deliver_request for the reviewed HTML and that reviewer-authored verification record; closing alone does not complete delivery.', - send_message: 'Deliver the artifact and task/review evidence through the existing chief → assistant → user path, with send_message in_reply_to the original source_message so it returns to the originating session. Never coordinate the assistant directly with PM or reviewer.' }[intent.step]); + send_message: 'Deliver the artifact and task/review evidence through the existing chief → assistant → user path, with send_message in_reply_to the original source_message so it returns to the originating session. Never coordinate the assistant directly with PM or reviewer.' }[intent.step]) + quoteBlock(intent.step, quote); return services.continuationMessage(state, intent.target, body, context); } - function attempt(state, row, context, initialMessage = null) { + function attempt(state, row, context, initialMessage = null, quote = null) { const intent = row.result.intent, rows = effects(state); const attempts = rows.filter(e => e.stage === 'committed' && e.result?.type === 'attempt' && e.result.key === intent.key); const last = attempts.at(-1)?.result; @@ -80,12 +96,12 @@ export function createContinuations({ journal, services }) { message = last.message; if (!context.run) services.continuationRetry?.(state, message, intent.target, context); } - message ??= send(state, intent, context, wake); + message ??= send(state, intent, context, wake, quote); receipt(state, `attempt:${intent.key}:${attempts.length + 1}`, { type: 'attempt', key: intent.key, attempt: attempts.length + 1, token, run: context.run, prerequisite: context.prerequisite, progress, message }); }); } - function resume(state) { + function resume(state, quote = null) { if (!services.continuationContext || !services.continuationMessage) return; for (const row of unfinished(state)) { const intent = row.result.intent; @@ -104,11 +120,11 @@ export function createContinuations({ journal, services }) { journal.transaction(() => { const header = { version: 1, business: state.business, request: state.request, kind: 'continuation', op, digest: digest(intent) }; journal.prepareEffect({ ...header, stage: 'prepared', result: null, code: null }); - const message = send(state, intent, context); + const message = send(state, intent, context, null, quote); row = journal.finishEffect({ ...header, stage: 'uncertain', result: { intent, message }, code: null }); attempt(state, row, context, message); }); - } else attempt(state, row, context); + } else attempt(state, row, context, null, quote); } } function launches(business) { diff --git a/packages/bus/src/request-lifecycle.mjs b/packages/bus/src/request-lifecycle.mjs index 5d63c0d9a..8bb932ec5 100644 --- a/packages/bus/src/request-lifecycle.mjs +++ b/packages/bus/src/request-lifecycle.mjs @@ -343,10 +343,18 @@ export function createRequestLifecycle({ journal, services }) { async function resume(business, request) { return serial(business, request, async () => { const state = await settle(business, request); - continuations.resume(state); + continuations.resume(state, await quoteFor(state)); return stateOf(business, request); }); } + // D3 (#797): implementation/review continuations quote the task-cited approved requirement texts. + // The texts come only from the broker's host-side resolver (the same catalog the scoped + // `requirements` verb reads); a refusal degrades to the ask-PM line inside the message and never + // blocks the task. Resolved before the journal transaction because the resolver reads Git. + const quoteFor = async (state, { resolved = null, phases = ['implementation', 'review'] } = {}) => { + if (!services.continuationQuote || !state?.snapshot || !phases.includes(state.phase)) return null; + try { return await services.continuationQuote(state, resolved); } catch { return { unavailable: true }; } + }; // The Task 3 verbs share one shape: validate, authorize, replay a recorded op, then check revision and phase in // that order, so a lost response can be retried and a stale client always learns the revision moved. const need = name => typeof services[name] === 'function' ? services[name] : refuse('request-services-unavailable'); @@ -445,14 +453,20 @@ export function createRequestLifecycle({ journal, services }) { if (state.revision !== args.revision) refuse('request-revision-conflict'); if (state.phase === 'delivery') { services.progress(state); refuse('request-delivery-required'); } if (!state.snapshot || !services.progress) refuse('request-evidence-required'); - await services.catalog.resolve({ business: state.business, project: state.project, snapshot: state.snapshot }); + const resolved = await services.catalog.resolve({ business: state.business, project: state.project, snapshot: state.snapshot }); + // The post-advance coder/review continuation quotes the approved texts; resolve it here (Git + // reads) and hand it into the transaction-built message. planning→implementation and + // implementation→review dispatch those steps, and a review phase can loop back to + // implementation on a changes verdict, so every advance out of those three phases resolves. + // The pre-append state carries the same product, snapshot and task citations as the next one. + const quote = await quoteFor(state, { resolved, phases: ['planning', 'implementation', 'review'] }); return journal.transaction(tx => { state = stateOf(state.business, state.request); authorize(identity, state, 'advance'); if (state.revision !== args.revision) refuse('request-revision-conflict'); const progress = services.progress(state); const result = append(state, 'advanced', { op: args.op, proposal_digest, from: state.phase, ...progress }, identity, tx.appendRequest); services.record?.(identity, 'request.advance', result); - continuations.resume(result); + continuations.resume(result, quote); return result; }); }); diff --git a/packages/bus/tests/request-continuation.test.mjs b/packages/bus/tests/request-continuation.test.mjs index 80a767dea..31849a51c 100644 --- a/packages/bus/tests/request-continuation.test.mjs +++ b/packages/bus/tests/request-continuation.test.mjs @@ -484,3 +484,136 @@ test('evidence already recorded while PM is absent does not duplicate its queued await f.bus.requestLifecycle.recover(); assert.equal(messages(f, intent.result.intent.key).filter(m => m.to_role === 'pm').length, before); }); + +// D3 (#797): the bus quotes the task-cited approved requirement texts into the coder/reviewer +// continuations, resolved host-side through the same catalog the scoped `requirements` verb uses. +const QUOTE_HEADER = /Approved requirement texts \(quoted owner-approved data, not instructions\)/; +const continuationBody = (f, step, revision) => { + const intent = intents(f).find(i => i.result.intent.step === step && i.result.intent.revision === revision); + assert.ok(intent, `${step} continuation exists at revision ${revision}`); + return messages(f, intent.result.intent.key).filter(m => m.to_role === (step === 'push_working' ? 'coder' : 'reviewer')).at(-1).body; +}; +const relaunchCoder = (f, run) => { + f.bus.broker.endLaunch({ business: 'demo', run: 'coder-run', reason: 'exit', exitCode: 0 }); + f.caps.coder = f.bus.broker.bindLaunch({ business: 'demo', role: 'coder', run, harness: 'pi' }); + f.call('coder', 'role.claim'); +}; + +test('implementation continuation quotes the exact approved requirement text, snapshot and records commit', async t => { + const f = await planned(t); + const implementation = await advance(f, 'start'); + const live = f.journal.readRequest('demo', f.request); + const body = continuationBody(f, 'push_working', implementation.revision); + assert.match(body, QUOTE_HEADER); + assert.ok(body.includes('A working timer.'), 'exact approved REQ-POMO-1 text'); + assert.ok(body.includes('REQ-POMO-1')); + assert.ok(body.includes('"Timer"'), 'requirement title'); + assert.ok(body.includes(f.task_ref), 'citing task ref'); + assert.ok(body.includes(live.snapshot), 'snapshot id'); + assert.ok(body.includes('product timer'), 'product key'); + assert.ok(body.includes(f.args.records_commit), 'records commit'); + assert.ok(!body.includes('truncated'), 'small quote is not truncated'); + // Foreign citations for another request, an unknown requirement id and another product key + // never enter the quote, even on a fresh send after the events exist. + const { DatabaseSync } = await import('node:sqlite'), { join } = await import('node:path'); + const db = new DatabaseSync(join(f.f.dataRoot, 'bus/bus.sqlite')); t.after(() => db.close()); + const at = new Date().toISOString(), snapshot = f.journal.readRequest('demo', f.request).snapshot; + const other = f.call('human', 'message.send', { to: 'pm', body: 'A second product request' }); + const foreign = [ + ['vikunja:2/99', { request: other.request, requirement: 'REQ-POMO-1', project_key: 'timer', requirement_snapshot: snapshot }], + ['vikunja:2/77', { request: f.request, requirement: 'REQ-POMO-1', project_key: 'other-product', requirement_snapshot: snapshot }], + ['vikunja:2/88', { request: f.request, requirement: 'REQ-FAKE-9', project_key: 'timer', requirement_snapshot: snapshot }]]; + for (const [n, [subject, body]] of foreign.entries()) + db.prepare("INSERT INTO events(id,at,business,kind,subject,body) VALUES(?,?,'demo','task.created',?,?)") + .run(`foreign-${n}`, at, subject, JSON.stringify(body)); + const drain = () => { const ms = f.call('coder', 'message.receive'); for (const m of ms) f.call('coder', 'message.read', { id: m.id }); }; + drain(); + relaunchCoder(f, 'coder-quote-run'); + await f.bus.requestLifecycle.recover(); + const fresh = continuationBody(f, 'push_working', implementation.revision); + assert.match(fresh, QUOTE_HEADER); + assert.ok(fresh.includes('A working timer.')); + for (const marker of ['vikunja:2/99', 'vikunja:2/77', 'vikunja:2/88', 'REQ-FAKE-9']) + assert.ok(!fresh.includes(marker), `${marker} from another request, product or snapshot stays out`); +}); + +test('review continuation quotes the same approved requirement texts for acceptance', async t => { + const f = await planned(t); + await advance(f, 'start'); + const commit = 'a'.repeat(40); f.push(commit); f.review(commit); + const review = await advance(f, 'review'); + const live = f.journal.readRequest('demo', f.request); + const body = continuationBody(f, 'review_verdict', review.revision); + assert.match(body, QUOTE_HEADER); + assert.ok(body.includes('A working timer.')); + assert.ok(body.includes(live.snapshot)); + assert.ok(body.includes('product timer')); + assert.ok(body.includes(f.args.records_commit)); + assert.ok(body.includes(f.task_ref)); +}); + +test('an unresolvable snapshot degrades the quote to the ask-PM line without blocking', async t => { + const f = await planned(t); + const implementation = await advance(f, 'start'); + assert.match(continuationBody(f, 'push_working', implementation.revision), QUOTE_HEADER); + const drain = who => { const ms = f.call(who, 'message.receive'); for (const m of ms) f.call(who, 'message.read', { id: m.id }); return ms; }; + drain('coder'); + // Move the records past the approved candidate: the resolver refuses prd-candidate-stale. A read + // predecessor run means the successor gets a fresh send, which must degrade, not throw or block. + f.save('requirements/REQ-POMO-1.json', JSON.stringify({ recordVersion: 1, id: 'REQ-POMO-1', title: 'Timer', text: 'Drifted text.', status: 'draft' }), 'pm.pm'); + relaunchCoder(f, 'coder-stale-run'); + await f.bus.requestLifecycle.recover(); + const stale = continuationBody(f, 'push_working', implementation.revision); + assert.ok(stale.includes('could not be resolved from the records'), 'ask-PM fallback line'); + assert.ok(stale.includes('ask the PM')); + assert.ok(!stale.includes('A working timer.'), 'no quoted text on failure'); + assert.ok(!stale.includes('Drifted text.'), 'never the drifted, unapproved text'); + // A revoked snapshot (the resolver's requirement-snapshot-refused branch) degrades the same + // closed way, still without a throw: append the revocation event the console verb would record. + const { DatabaseSync } = await import('node:sqlite'), { join } = await import('node:path'); + const db = new DatabaseSync(join(f.f.dataRoot, 'bus/bus.sqlite')); t.after(() => db.close()); + const revision = 1 + (db.prepare('SELECT max(revision) n FROM request_snapshot_events WHERE business=? AND project=?').get('demo', 'timer').n ?? 0); + db.prepare('INSERT INTO request_snapshot_events(at,business,project,revision,snapshot,kind,cause) VALUES(?,?,?,?,?,?,?)') + .run(new Date().toISOString(), 'demo', 'timer', revision, implementation.snapshot, 'revoked', JSON.stringify({ op: 'fixture-revoke', reason: 'test', by: 'jason' })); + drain('coder'); + f.bus.broker.endLaunch({ business: 'demo', run: 'coder-stale-run', reason: 'exit', exitCode: 0 }); + f.caps.coder = f.bus.broker.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-missing-run', harness: 'pi' }); + f.call('coder', 'role.claim'); + await f.bus.requestLifecycle.recover(); + const missing = continuationBody(f, 'push_working', implementation.revision); + assert.ok(missing.includes('could not be resolved from the records')); + assert.ok(!missing.includes('A working timer.')); +}); + +test('the quote is bounded: past the budget it truncates and says to ask the PM', async t => { + const f = await requestFixture(t); + for (let i = 2; i <= 5; i++) { + const id = `REQ-POMO-${i}`; + f.args.records_commit = f.save(`requirements/${id}.json`, + JSON.stringify({ recordVersion: 1, id, title: `Timer ${i}`, text: `Text ${i} ` + 'x'.repeat(5990) + ` END-${i}`, status: 'draft' }), 'pm.pm'); + f.args.requirement_ids.push(id); + } + const sub = await f.submit(); f.approve(sub); await f.activate(sub); + const { createDigest, createMarker } = await import('../src/broker.mjs'); + const s = f.journal.readRequest('demo', f.request); + const create = i => { + const task_ref = `vikunja:2/${40 + i}`; + const prepared = { op: `timer-task-${i}`, request: f.request, requirement: `REQ-POMO-${i}`, project_key: 'timer', requirement_snapshot: s.snapshot, + project: 2, creator: 3, decision: null, view: { ids: { todo: 1, review: 2, done: 3 } }, labels: [], relations: [] }; + prepared.body = { title: `Timer ${i}`, description: createMarker(prepared) }; + prepared.body_digest = createDigest(prepared.body); + const { receipt } = f.bus.broker.prepareTaskCreate(f.caps.pm, prepared); + let fields = { ...prepared.body, project_id: 2, bucket: 1, done: false, assignees: [], due_date: null, priority: 0 }; + const snapshot = () => ({ task_ref, fields, digest: createDigest(fields), updated: new Date().toISOString() }); + f.bus.broker.recordRecoveredCreate({ receipt, task_ref, snapshot: { ...snapshot(), creator: 3 } }); + fields = { ...fields, assignees: [5] }; + f.bus.broker.recordTask({ cap: f.caps.pm, business: 'demo', snapshots: [snapshot()], events: [{ kind: 'task.assigned', subject: task_ref, body: { role: 'coder', previous: [] } }] }); + }; + for (let i = 1; i <= 5; i++) create(i); + const implementation = await advance(f, 'start'); + const body = continuationBody(f, 'push_working', implementation.revision); + assert.ok(body.includes('A working timer.') && body.includes('END-2') && body.includes('END-3'), 'early requirements quoted in full'); + for (const i of [4, 5]) assert.ok(!body.includes(`END-${i}`), `requirement ${i} text is cut by the budget`); + assert.ok(body.includes('REQ-POMO-5'), 'every cited id is still listed'); + assert.match(body, /truncated at the continuation budget; ask the PM/); +}); diff --git a/roles/coder.md b/roles/coder.md index 370b05e02..4f0c7e8e4 100644 --- a/roles/coder.md +++ b/roles/coder.md @@ -13,6 +13,9 @@ assigned to you and hand finished work to review. Do not treat message delivery/read as completion or claim a lifecycle phase. If your frozen tool manifest lacks the product, report it through the chief path and finish the current turn before its normal relaunch. +- The implementation continuation quotes the task-cited approved requirement + texts (product, snapshot id, records commit) as data; when that quote is + absent, ask the PM for the exact approved texts instead of guessing. - Work only on tasks assigned to you. Update a task's state and comments as the work moves. diff --git a/roles/reviewer.md b/roles/reviewer.md index 02dca2c8c..2bf229b79 100644 --- a/roles/reviewer.md +++ b/roles/reviewer.md @@ -12,6 +12,10 @@ candidates and record a verdict. You don't change the code you review. latest commit. Reading a continuation is not completion. Report a missing product manifest through the chief path and let your current turn drain before its normal relaunch; do not interrupt other work. +- The review continuation quotes the task-cited approved requirement texts + (product, snapshot id, records commit) as the acceptance reference; when + that quote is absent, ask the PM for the exact approved texts instead of + guessing. - For a lifecycle-scoped REVIEW-REQUEST, follow its raw JSON verification-document and verdict-body instructions before issuing the one irreversible verdict. The document is raw JSON without fences or prose. - Review the candidate named in the request against the task's From cecfcdd201cf3ed57148d4979e847117521bd671 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 5 Nov 2026 21:18:46 -0600 Subject: [PATCH 12/32] feat(webui): add project conversation surfaces (#797) --- packages/webui/src/public/chat.css | 10 ++ packages/webui/src/public/chat.js | 103 +++++++++++++---- packages/webui/src/public/user.js | 2 +- .../webui/tests/helpers/w15-conversations.mjs | 57 ++++++++++ .../tests/w15-conversations-browser.test.mjs | 107 ++++++++++++++++++ 5 files changed, 259 insertions(+), 20 deletions(-) create mode 100644 packages/webui/tests/helpers/w15-conversations.mjs create mode 100644 packages/webui/tests/w15-conversations-browser.test.mjs diff --git a/packages/webui/src/public/chat.css b/packages/webui/src/public/chat.css index ec088e694..2f7b6001a 100644 --- a/packages/webui/src/public/chat.css +++ b/packages/webui/src/public/chat.css @@ -65,3 +65,13 @@ .request-prd { margin-block: .75rem; border-block: 1px solid var(--border); padding-block: .5rem; } .request-prd pre { white-space: pre-wrap; overflow-wrap: anywhere; font-family: var(--mono); } .request-requirement { margin-block: .75rem; } + +/* Project conversation layout, scoped to this surface. */ +.chat-area { overflow-wrap: anywhere; } +.chat-area h1 { font-size: clamp(1.375rem, 2vw, 2rem); } +.chat-area .btn, .chat-area summary { min-height: 44px; } +.chat-details, .chat-files, .chat-room-picker { margin-block: 1rem; padding: .8rem; border: 1px solid var(--line); border-radius: 4px; } +.chat-details summary { cursor: pointer; } +.chat-room-picker label { display: block; padding-block: .45rem; } +.chat-audience { margin: .25rem 0; } +.chat-projects .panel { border-radius: 4px; } diff --git a/packages/webui/src/public/chat.js b/packages/webui/src/public/chat.js index d358afe05..430006a8e 100644 --- a/packages/webui/src/public/chat.js +++ b/packages/webui/src/public/chat.js @@ -6,7 +6,7 @@ const inert = value => String(value ?? '').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u202a-\u202e\u2066-\u2069]/g, c => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0')); const txt = value => esc(inert(value)); - const states = new Map(), forms = new Map(); + const states = new Map(), forms = new Map(), knownRequests = new Map(); let current = null, controller = null, generation = 0, signedOut = false; const out = () => signedOut || document.body.classList.contains('signed-out'); const state = id => { @@ -33,7 +33,7 @@ } const channelMessage = 'The chat channel is disabled for this business. Ask the person who manages it to enable WebUI chat, then refresh.'; function forget() { - generation++; controller?.abort(); states.clear(); forms.clear(); libraries.clear(); reverts.clear(); + generation++; controller?.abort(); states.clear(); forms.clear(); knownRequests.clear(); libraries.clear(); reverts.clear(); window.MosaicRequests?.forget(); if (current) current.container.replaceChildren(); current = null; @@ -64,7 +64,11 @@ } return data; } - const link = (type, id, label) => UUID.test(id) ? `${txt(label)}` : txt(label); + const legacyRoute = () => location.hash.startsWith('#/chat'); + const projectPath = id => legacyRoute() ? `#/chat/projects/${id}` : `#/projects/${id}`; + const threadPath = id => legacyRoute() ? `#/chat/threads/${id}` : `#/projects/threads/${id}`; + const roomPath = id => legacyRoute() ? `#/chat/rooms/${id}` : `#/projects/rooms/${id}`; + const link = (type, id, label) => UUID.test(id) ? `${txt(label)}` : txt(label); const heading = text => `

        ${txt(text)}

        `; const selectedInactive = () => { const s = current?.sessions?.find(r => r.id === current.session); return !!s?.state && s.state !== 'active'; }; const disabledSession = pending => disabled(pending || selectedInactive()); @@ -93,7 +97,7 @@ function threads(rows) { return rows.length ? `
          ${rows.map(t => `
        • ${link('threads', t.id, t.name)} ${txt(t.instance)}${t.shared ? ` · Shared by ${txt(t.owner)}` : ''}
        • `).join('')}
        ` : current.readFailed ? '

        Threads unavailable.

        ' : '

        No threads yet.

        '; } - const sessionPath = (thread, session) => `#/chat/threads/${thread}/sessions/${session}`; + const sessionPath = (thread, session) => `${threadPath(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'})`; @@ -205,7 +209,7 @@ const withRequest = requestRows(s.rows); const messages = s.rows.map(m => `
      1. ${txt(m.from_role)} ${stamp(m.at)}
        ${messageBody(m)}${withRequest.has(m.id) ? `
        ` : ''}
      2. `).join(''); const receipt = s.receipt; - return `

        ${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}

        ${heading(t.name)}${libraryLine(libraryOf(current, t))}${sessionControls(t)}${sharing(t)}

        Reading never wakes the assistant. An accepted message is not an answer.

        ${threadWrites() ? `` : ''}
          ${messages || (current.readFailed ? '
        1. Messages unavailable.
        2. ' : '
        3. No messages yet.
        4. ')}
        ${current.readFailed ? '' : s.more ? '

        Showing earlier history. Read forward to reach the current conversation.

        ' : '

        Caught up to the last read.

        '}
        ${threadWrites() ? `
        ` : ''}

        ${txt(receipt)}

        `; + return `

        ${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}

        ${heading(t.name)}${libraryLine(libraryOf(current, t))}
        Conversation details${sessionControls(t)}${sharing(t)}

        Reading never wakes the assistant. An accepted message is not an answer.

        ${threadWrites() ? `` : ''}
          ${messages || (current.readFailed ? '
        1. Messages unavailable.
        2. ' : '
        3. No messages yet.
        4. ')}
        ${current.readFailed ? '' : s.more ? '

        Showing earlier history. Read forward to reach the current conversation.

        ' : '

        Caught up to the last read.

        '}
        ${threadWrites() ? `

        To: ${txt(t.instance)} · Private conversation

        ` : ''}

        ${txt(receipt)}

        Files

        Files unavailable.
        `; } // C2-10c: rooms reuse the same state, renderer and generation fences as threads. const ROOM_LINES = Object.freeze({ @@ -232,7 +236,7 @@ function roomOpenControl(project) { if (!roomOwner()) return ''; const f = form('room-open:' + project); - return `
        Read room

        ${txt(f.receipt)}

        `; + return `
        Read room

        ${txt(f.receipt)}

        `; } function roomConversation() { const c = current, s = state(c.key), p = c.projects.find(p => p.id === c.id); @@ -242,7 +246,7 @@ return `
      3. ${txt(speaker)}${recipients} ${stamp(m.at)}
        ${messageBody({ ...m, from_role: m.from_role === 'human' ? m.from_role : speaker })}
      4. `; }).join(''); const off = !c.roomReady || c.channelDisabled || s.pending || !roomOwner(); - return `

        ${link('projects', c.id, p?.name ?? 'Project')}

        ${heading((p?.name ?? 'Project') + ' room')}${roomOpenControl(c.id)}${libraryLine(libraryOf(c))}

        Room members: ${(c.members ?? []).map(m => `${txt(m.name)} (@${txt(m.instance)})`).join(', ') || 'unavailable'}

          ${messages || (c.readFailed ? '
        1. Messages unavailable.
        2. ' : '
        3. No messages yet.
        4. ')}
        ${c.readFailed ? '' : s.more ? '' : '

        Caught up to the last read.

        '}

        Without leading @tags, your message goes to every member. The broker checks the addressees.

        ${txt(s.receipt)}

        `; + return `

        ${link('projects', c.id, p?.name ?? 'Project')}

        ${heading((p?.name ?? 'Project') + ' room')}${roomOpenControl(c.id)}${libraryLine(libraryOf(c))}

        Messages are copied to selected contacts. Their replies are private.

        Room members: ${(c.members ?? []).map(m => `${txt(m.name)} (@${txt(m.instance)})`).join(', ') || 'unavailable'}

          ${messages || (c.readFailed ? '
        1. Messages unavailable.
        2. ' : '
        3. No messages yet.
        4. ')}
        ${c.readFailed ? '' : s.more ? '' : '

        Caught up to the last read.

        '}
        Choose recipients${(c.members ?? []).map(m => ``).join('')}

        Choose contacts above, or use leading @tags. Without tags, the message goes to all room members. Mentions in the message do not add recipients. The broker checks every address.

        ${txt(s.receipt)}

        `; } function drawRoomCompletion() { const list = document.getElementById('chat-room-completion'), field = document.getElementById('chat-room-body'); @@ -257,8 +261,12 @@ if (current?.kind !== 'room') return; const field = document.getElementById('chat-room-body'), preview = document.getElementById('chat-room-tags'); if (!field || !preview) return; - const { tags } = roomAddress(field.value); - preview.innerHTML = tags.length ? 'Typed addressees: ' + tags.map(tag => `@${txt(tag)}`).join(' ') : 'All room members'; + const { tags, body } = roomAddress(field.value); + const offer = document.getElementById('chat-room-offer'); + const mentioned = tags.length ? [...body.matchAll(/@([A-Za-z0-9][A-Za-z0-9_.-]*)/g)].map(m => m[1]).find(name => !tags.includes(name) && current.members.some(m => m.instance === name)) : null; + if (offer) offer.innerHTML = mentioned ? `` : ''; + preview.innerHTML = tags.length ? 'To: ' + tags.map(tag => `@${txt(tag)}`).join(' ') : 'To: all room members'; + for (const box of document.querySelectorAll('[data-room-member]')) box.checked = tags.length ? tags.includes(box.dataset.roomMember) : true; const prefix = field.value.slice(0, field.selectionStart), token = /(?:^|\s)@([^\s@]*)$/u.exec(prefix); current.completion = null; if (document.activeElement === field && !field.disabled && token && !roomAddress(prefix.slice(0, token.index)).body.trim()) { @@ -328,8 +336,8 @@ if (data.project !== id || !Array.isArray(data.threads) || !data.threads.length || data.threads.some(t => !UUID.test(t?.id) || typeof t.instance !== 'string' || t.name !== t.instance)) throw new ChatError('outcome-unknown'); f.receipt = 'Room opened.'; if (current?.id === id) { - if (location.hash === '#/chat/rooms/' + id) void refresh(); - else location.hash = '#/chat/rooms/' + id; + if (current?.kind === 'room' && current.id === id) void refresh(); + else location.hash = roomPath(id); } } catch (err) { if (out() || err.code === 'signed-out') return; @@ -372,12 +380,12 @@ if (!current) return null; const active = document.activeElement, log = current.container.querySelector('.chat-log'); const field = active?.id?.startsWith('chat-') ? { id: active.id, start: active.selectionStart, end: active.selectionEnd } : null; - return { field, top: log?.scrollTop, tail: log ? log.scrollHeight - log.scrollTop - log.clientHeight < 40 : true }; + return { field, detailsOpen: current.container.querySelector('.chat-details')?.open, top: log?.scrollTop, tail: log ? log.scrollHeight - log.scrollTop - log.clientHeight < 40 : true }; } function paint(navigated = false) { if (!current || out()) return; const keep = capture(), c = current; - let html = '

        Chat projects

        '; + let html = '

        Projects

        '; if (c.error) html += ``; 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') { @@ -389,15 +397,35 @@ } 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 += roomOpenControl(c.id) + threads(c.threads.filter(t => !t.shared)) + '

        Shared with me

        ' + threads(c.threads.filter(t => t.shared)) + creation('thread', c.id); + if (p && !c.refused) { + if (c.settings) html += `

        Project overview

        Project information

        Repository: ${txt(p.repo || 'Not linked')}

        Repository setting unavailable.
        `; + else html += `

        Project information

        ${knownRequests.get(c.id)?.repo === p.repo && knownRequests.get(c.id)?.ids.size ? '

        Request status from conversations opened in this session. Other work may not be shown.

        ' : '

        Request status is not loaded. Open a conversation to see its requests.

        '}

        Conversations

        ` + roomOpenControl(c.id) + threads(c.threads.filter(t => !t.shared)) + '

        Shared with me

        ' + threads(c.threads.filter(t => t.shared)) + creation('thread', c.id); + } } else { - html += heading('Chat') + '

        Business projects and your named assistant threads.

        ' + + html += heading('Projects') + '

        Projects and their conversations.

        ' + c.projects.map(p => `

        ${link('projects', p.id, p.name)}

        ${p.repo ? `

        Repository: ${txt(p.repo)}

        ` : ''}${threads(c.threads.filter(t => t.project === p.id && !t.shared))}
        `).join('') + (c.projects.length ? '' : c.readFailed ? '

        Projects unavailable.

        ' : '

        No projects yet.

        ') + '

        Shared with me

        ' + threads(c.threads.filter(t => t.shared)) + creation('project', 'projects'); } 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); + 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); + const remembered = c.kind === 'project' && cached && cached.repo === repo ? cached.ids : []; + const ids = !roomOwner() ? [] : t ? [...new Set(state(c.key).rows.filter(m => m.from_role === 'human').map(m => m.request).filter(id => typeof id === 'string' && REQUEST_ID.test(id)))] : [...remembered]; + const files = c.container.querySelector('[data-chat-files]'); + if (files && typeof window.MosaicFiles?.mount === 'function') { files.replaceChildren(); window.MosaicFiles.mount({ container: files, requestIds: ids }); } + const summary = c.container.querySelector('[data-chat-summary]'); + if (summary && repo && ids.length) { + if (typeof window.MosaicRequests?.projectSummary === 'function') window.MosaicRequests.projectSummary({ container: summary, requestIds: ids, project: repo }); + else summary.textContent = 'Request summary unavailable.'; + } + const remote = c.container.querySelector('[data-chat-remote]'); + const project = c.projects.find(p => p.id === c.id); + if (remote && roomOwner() && typeof window.MosaicProjectRemote?.mount === 'function') { remote.replaceChildren(); window.MosaicProjectRemote.mount({ container: remote, projectId: c.id, repositoryKey: project?.repo ?? null }); } document.title = `${c.container.querySelector('h1')?.textContent ?? 'Chat'} · Mosaic Console`; + const details = c.container.querySelector('.chat-details'); + if (details && keep?.detailsOpen) details.open = true; const log = c.container.querySelector('.chat-log'); if (log) { const s = state(c.key ?? c.id); @@ -421,6 +449,12 @@ } async function render({ path, container, canSend, user = false, tester = false, navigated = false, next = false }) { if (out()) return; + const originalPath = path; + const settings = /^\/projects\/([0-9a-f-]+)\/settings$/i.exec(path); + if (settings) path = '/chat/projects/' + settings[1]; + else if (path === '/projects') path = '/chat'; + else if (/^\/projects\/(threads|rooms)\//.test(path)) path = '/chat/' + path.slice('/projects/'.length); + else if (/^\/projects\/[0-9a-f-]+$/i.test(path)) path = '/chat/projects/' + path.slice('/projects/'.length); if (/^\/chat\/rooms(?:\/|$)/.test(path)) return renderRoom({ path, container, canSend, user, tester, navigated, next }); const match = /^\/chat(?:\/(projects|threads)\/([0-9a-f-]+)(?:\/sessions\/([0-9a-f-]+))?)?$/.exec(path); controller?.abort(); controller = new AbortController(); @@ -432,7 +466,7 @@ paint(); return; } const old = current; - 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: '', 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, 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…

        '; try { if (user) { @@ -548,6 +582,17 @@ } catch (err) { if (err.name === 'AbortError') return; } } if (g === generation) { + if ((kind === 'thread' && current.thread || kind === 'project') && !current.refused) { + const projectId = kind === 'thread' ? current.thread.project : id; + const project = current.projects.find(p => p.id === projectId); + if (project && kind === 'thread' && roomOwner() && typeof project.repo === 'string') { + const prior = knownRequests.get(projectId); + const ids = prior?.repo === project.repo ? prior.ids : new Set(); + for (const row of state(current.key).rows) if (row.from_role === 'human' && typeof row.request === 'string' && REQUEST_ID.test(row.request)) ids.add(row.request); + knownRequests.set(projectId, { repo: project.repo, ids }); + } + if (project) document.dispatchEvent(new CustomEvent('mosaic-project-context', { detail: { projectId, repositoryKey: project.repo ?? null, thread: kind === 'thread' ? id : null, session: kind === 'thread' ? current.session : null } })); + } paint(navigated); const t = kind === 'thread' ? current.threads.find(t => t.id === id) : null; if (t) void loadLibrary(libraryOf(current, t), g, signal); @@ -560,6 +605,7 @@ // A bad session selector is local to that read. Only a thread-level refusal // invalidates all sibling drafts, receipts and recovery operation keys. if (!err.sharedRead && ['thread-not-owned', 'thread-not-found', 'access-refused'].includes(err.code)) { + knownRequests.clear(); for (const key of states.keys()) if (key === id || key.startsWith(id + '/')) states.delete(key); for (const key of forms.keys()) if (key.includes(id)) forms.delete(key); } @@ -573,7 +619,7 @@ if (current) return render({ ...current, path: location.hash.slice(1).split('?')[0], next }); } function leave(path) { - if (!path.startsWith('/chat')) { generation++; controller?.abort(); current = null; } + if (!path.startsWith('/chat') && !path.startsWith('/projects')) { generation++; controller?.abort(); current = null; } } document.addEventListener('input', event => { if (!current || out()) return; @@ -619,7 +665,7 @@ } else if (action === 'open') s.receipt = 'Assistant resume requested.'; else { s.name = ''; s.repo = ''; s.op = null; s.payload = null; - if (location.hash === startedRoute) location.hash = action === 'createSession' ? sessionPath(targetThread, result.id) : '#/chat/' + (action === 'createProject' ? 'projects/' : 'threads/') + result.id; + if (location.hash === startedRoute) location.hash = action === 'createSession' ? sessionPath(targetThread, result.id) : (action === 'createProject' ? projectPath(result.id) : threadPath(result.id)); } } catch (err) { if (out() || err.code === 'signed-out') return; @@ -666,6 +712,14 @@ document.addEventListener('click', event => { if (!current || out()) return; if (event.target.id === 'chat-room-open') void openRoom(); + const offer = event.target.closest?.('[data-room-offer]'); + if (offer && current.kind === 'room') { + const field = document.getElementById('chat-room-body'), parsed = roomAddress(field.value), name = offer.dataset.roomOffer; + if (parsed.tags.length && current.members.some(m => m.instance === name) && !parsed.tags.includes(name)) { + state(current.key).draft = [...parsed.tags, name].map(tag => '@' + tag).join(' ') + ' ' + parsed.body; + paint(); + } + } const option = event.target.closest?.('[data-room-complete]'); if (option) completeRoom(option.dataset.roomComplete); if (event.target.dataset.unshare) void changeShare('unshare', event.target.dataset.unshare); @@ -678,6 +732,17 @@ if (event.target.id === 'chat-new') { state(current.key ?? current.id).tailRequested = true; state(current.key ?? current.id).follow = true; paint(); } }); document.addEventListener('change', event => { + if (event.target.matches?.('[data-room-member]') && current?.kind === 'room') { + const field = document.getElementById('chat-room-body'), s = state(current.key); + const parsed = roomAddress(field.value); + if (parsed.tags.some(tag => !current.members.some(m => m.instance === tag))) { + s.receipt = 'Repair the unresolved address before changing recipients. Your draft is kept.'; paint(); return; + } + const chosen = [...document.querySelectorAll('[data-room-member]:checked')].map(el => el.dataset.roomMember); + if (!chosen.length) { s.receipt = 'Choose at least one recipient. Your draft is kept.'; paint(); return; } + s.draft = chosen.map(name => '@' + name).join(' ') + (parsed.body ? ' ' + parsed.body : ' '); + s.receipt = ''; paint(); return; + } if (event.target.id === 'chat-session' && current && UUID.test(event.target.value)) { location.hash = sessionPath(current.id, event.target.value); } diff --git a/packages/webui/src/public/user.js b/packages/webui/src/public/user.js index 99c9e6724..65656e6b5 100644 --- a/packages/webui/src/public/user.js +++ b/packages/webui/src/public/user.js @@ -6,7 +6,7 @@ let canSend = false, ready = false; const route = () => { const path = location.hash.slice(1).split('?')[0]; - return path.startsWith('/chat') ? path : '/chat'; + return path.startsWith('/chat') || path.startsWith('/projects') ? path : '/projects'; }; function show(navigated) { if (ready && window.MosaicChat) void window.MosaicChat.render({ path: route(), container: $('chat'), canSend, user: true, navigated }); diff --git a/packages/webui/tests/helpers/w15-conversations.mjs b/packages/webui/tests/helpers/w15-conversations.mjs new file mode 100644 index 000000000..d424d249a --- /dev/null +++ b/packages/webui/tests/helpers/w15-conversations.mjs @@ -0,0 +1,57 @@ +import { writeFile, mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { fixture } from '../fixture.mjs'; +import { browser } from '../browser.mjs'; +export const P = '10000000-0000-4000-8000-000000000001'; +export const T = '20000000-0000-4000-8000-000000000001'; +export const S = '30000000-0000-4000-8000-000000000001'; +export const S2 = '30000000-0000-4000-8000-000000000002'; +const stub = `(() => { + const real = fetch.bind(window), P=${JSON.stringify(P)}, T=${JSON.stringify(T)}, S=${JSON.stringify(S)}, S2=${JSON.stringify(S2)}; + const json=(v,status=200)=>new Response(JSON.stringify(v),{status,headers:{'content-type':'application/json'}}); + window.__writes=[]; window.__unexpected=[];window.__roomRows=[];window.confirm=()=>true; window.__mounts=[]; window.__contexts=[]; + window.MosaicFiles={mount:o=>{window.__mounts.push(['files',o.requestIds]);o.container.textContent='Fixture files';}}; + window.MosaicProjectRemote={mount:o=>{window.__mounts.push(['remote',o.projectId,o.repositoryKey]);o.container.textContent='Fixture remote';}}; + document.addEventListener('mosaic-project-context',e=>window.__contexts.push(e.detail)); + window.fetch=async (url,init={})=>{ + const path=String(url), method=init.method||'GET'; if(!path.startsWith('/api/')) return real(url,init); + if(method!=='GET'){window.__writes.push({path,body:JSON.parse(init.body||'{}')}); if(window.__holdClear && path.endsWith('/clear'))return new Promise(r=>window.__releaseClear=()=>r(json({error:'fixture-refusal',sent:false},409))); if(window.__roomAccept && path.includes('/rooms/') && path.endsWith('/messages')){const id='50000000-0000-4000-8000-000000000001';window.__roomRows.push({id,ids:[id],from_role:'human',to:['alice'],body:JSON.parse(init.body).body,at:'2026-11-05T12:00:00Z'});return json({ids:[id],to:['alice']});} return json({error:'fixture-refusal',sent:false},409);} + if(path==='/api/config')return json({send:true,chatGrants:[]}); + if(path.startsWith('/api/bus/'))return json({rows:[],unread:0,at:'2026-11-05T12:00:00Z'}); + if(path==='/api/business')return json({projects:['repo-key']}); + if(path.startsWith('/api/library/status'))return json({error:'not-configured'},404); + if(path==='/api/chat/projects')return json({rows:[{id:P,name:'Active project',repo:'repo-key'},{id:'10000000-0000-4000-8000-000000000002',name:'Empty project',repo:null},{id:'10000000-0000-4000-8000-000000000003',name:'Archived project',repo:null,archived:true}]}); + if(path==='/api/chat/instances')return json({rows:[{instance:'alice',name:'Alice'},{instance:'bob',name:'Bob'}]}); + if(path.includes('/shares'))return json({rows:[]}); + if(path.endsWith('/sessions'))return json({rows:[{id:S,name:'Planning',default:true,state:'active',revision:1},{id:S2,name:'Review',state:'active',revision:1}]}); + if(path.includes('/rooms/') && path.includes('/messages'))return json({rows:window.__roomRows,after:null,hasMore:false}); + if(path.includes('/messages')) { if(window.__failRead)return json({error:'offline'},503); return json({rows:[{id:'40000000-0000-4000-8000-000000000001',from_role:'human',body:'A fixture message',request:'request-1',at:'2026-11-05T12:00:00Z'}],after:null,hasMore:false}); } + if(path.startsWith('/api/chat/threads'))return json({rows:path.includes('shared=true')?(window.__shared?[{id:T,project:P,name:'Planning conversation',instance:'alice',owner:'Owner'}]:[]):window.__shared?[]:[{id:T,project:P,name:'Planning conversation',instance:'alice'},{id:'20000000-0000-4000-8000-000000000002',project:P,name:'alice',instance:'alice'},{id:'20000000-0000-4000-8000-000000000003',project:P,name:'bob',instance:'bob'}]}); + window.__unexpected.push(path);return json({error:'unexpected-fixture-api'},403); + }; +})();`; +export async function setup(t) { + const f=await fixture(); t.after(()=>f.close()); + const b=await browser({machine:false}); t.after(()=>b.close()); + const errors=[]; + b.on('Runtime.exceptionThrown', p=>errors.push(p.exceptionDetails.text)); + await b.call('Fetch.enable',{patterns:[{urlPattern:'*'}]}); + b.on('Fetch.requestPaused', p=>{void b.call(p.request.url.startsWith(f.base+'/')?'Fetch.continueRequest':'Fetch.failRequest',p.request.url.startsWith(f.base+'/')?{requestId:p.requestId}:{requestId:p.requestId,errorReason:'BlockedByClient'}).catch(()=>{});}); + await b.call('Page.addScriptToEvaluateOnNewDocument',{source:stub}); + await b.viewport(1440,1000); await b.navigate(f.base+'/#/chat'); + const wait=expr=>b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw new Error(${JSON.stringify(expr)});})()`); + await wait('document.querySelector(".chat-projects")'); + // The shell owns the canonical route dispatcher and arrives at integration. Exercise the actual + // conversation renderer with canonical paths without changing the unowned baseline shell. + const render=async path=>{await b.evaluate(`MosaicChat.render({path:${JSON.stringify(path)},container:document.getElementById('bus-view'),canSend:true,navigated:false})`);}; + const shot=async (name,width)=>{ + if(!process.env.MOSAIC_UX_SHOTS)return; + await mkdir(process.env.MOSAIC_UX_SHOTS,{recursive:true});await b.viewport(width,1000);await b.evaluate('document.fonts.ready'); + if (await b.evaluate('document.documentElement.scrollWidth > document.documentElement.clientWidth')) throw new Error('Document overflows at '+width); + const file=join(process.env.MOSAIC_UX_SHOTS,`conversations-${name}-${width}.png`);await b.screenshot(file); + const {cssContentSize:s}=await b.call('Page.getLayoutMetrics'); + const {data}=await b.call('Page.captureScreenshot',{format:'png',captureBeyondViewport:true,clip:{x:0,y:0,width:Math.ceil(s.width),height:Math.ceil(s.height),scale:1}}); + await writeFile(file.replace('.png','-full.png'),Buffer.from(data,'base64')); + }; + return {f,b,wait,render,shot,errors}; +} diff --git a/packages/webui/tests/w15-conversations-browser.test.mjs b/packages/webui/tests/w15-conversations-browser.test.mjs new file mode 100644 index 000000000..8ff775278 --- /dev/null +++ b/packages/webui/tests/w15-conversations-browser.test.mjs @@ -0,0 +1,107 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { setup, P, T, S, S2 } from './helpers/w15-conversations.mjs'; + +test('canonical project and session routes preserve identity, drafts and optional mounts', async t=>{ + const {b,render,shot}=await setup(t); + await b.evaluate(`window.MosaicRequests={mount(){},projectSummary(o){window.__mounts.push(['summary',o.requestIds,o.project]);o.container.textContent='Fixture request summary';}}`); + await render(`/projects/${P}`); + assert.match(await b.evaluate('document.querySelector(".chat-area").textContent'),/Request status is not loaded/); + for(const w of [1440,390]) await shot('project-overview-unloaded',w); + await render(`/projects/${P}/settings`); + assert.deepEqual(await b.evaluate('window.__mounts.at(-1)'),['remote',P,'repo-key']); + await render(`/projects/threads/${T}/sessions/${S}`); + assert.equal(await b.evaluate('document.querySelector(".chat-conversation").dataset.session'),S); + assert.deepEqual(await b.evaluate('window.__contexts.at(-1)'),{projectId:P,repositoryKey:'repo-key',thread:T,session:S}); + assert.ok(await b.evaluate(`window.__mounts.some(x=>x[0]==='files' && x[1][0]==='request-1')`)); + assert.deepEqual(await b.evaluate('window.__mounts.at(-1)'),['summary',['request-1'],'repo-key']); + await b.evaluate('document.querySelector("#chat-body").value="first draft";document.querySelector("#chat-body").dispatchEvent(new Event("input",{bubbles:true}))'); + for(const w of [1440,390]) await shot('conversation-progress',w); + await b.evaluate('document.querySelector(".chat-details").open=true'); + for(const w of [1440,390]) await shot('conversation-details',w); + await render(`/projects/threads/${T}/sessions/${S2}`); + assert.equal(await b.evaluate('document.querySelector("#chat-body").value'),''); + await b.evaluate('document.querySelector("#chat-body").value="second draft";document.querySelector("#chat-body").dispatchEvent(new Event("input",{bubbles:true}))'); + for(const w of [1440,390]) await shot('two-named-sessions',w); + await render(`/projects/threads/${T}/sessions/${S}`); + assert.equal(await b.evaluate('document.querySelector("#chat-body").value'),'first draft'); + await render(`/projects/${P}`); + assert.match(await b.evaluate('document.querySelector(".chat-area").textContent'),/Other work may not be shown/); + assert.deepEqual(await b.evaluate('window.__mounts.at(-1)'),['summary',['request-1'],'repo-key']); + for(const w of [1440,390]) await shot('project-overview-known',w); + await b.evaluate('delete window.MosaicRequests'); await render(`/projects/${P}`); + assert.match(await b.evaluate('document.querySelector("[data-chat-summary]").textContent'),/unavailable/); + await render(`/projects/threads/${T}/sessions/${S}`); + await b.evaluate('window.__failRead=true');await render(`/projects/threads/${T}/sessions/${S}`); + assert.equal(await b.evaluate('document.querySelector("#chat-body").value'),'first draft'); + for(const w of [1440,390]) await shot('read-failed-draft-kept',w); + assert.equal(await b.evaluate('window.__writes.length'),0); +}); + +test('room picker serializes selected instance and retains unresolved drafts', async t=>{ + const {b,render,shot}=await setup(t);await render(`/projects/rooms/${P}`); + assert.match(await b.evaluate('document.querySelector(".chat-room").textContent'),/replies are private/); + for(const w of [1440,390]) await shot('room-picker',w); + await b.evaluate('document.querySelector("[data-room-member=bob]").click()'); + assert.match(await b.evaluate('document.querySelector("#chat-room-body").value'),/^@alice /); + assert.match(await b.evaluate('document.querySelector("#chat-room-tags").textContent'),/To:.*alice/); + await b.evaluate('document.querySelector("#chat-room-body").value="@alice Please ask @bob too";document.querySelector("#chat-room-body").dispatchEvent(new Event("input",{bubbles:true}))'); + assert.match(await b.evaluate('document.querySelector("#chat-room-tags").textContent'),/To:.*alice/); + assert.equal(await b.evaluate('document.querySelector("[data-room-offer=bob]") !== null'),true); + await b.evaluate('document.querySelector("[data-room-offer=bob]").click()'); + assert.match(await b.evaluate('document.querySelector("#chat-room-tags").textContent'),/alice.*bob/); + await b.evaluate('document.querySelector("#chat-room-body").value="@unknown message";document.querySelector("#chat-room-body").dispatchEvent(new Event("input",{bubbles:true}));document.querySelector("[data-room-member=bob]").click()'); + assert.equal(await b.evaluate('document.querySelector("#chat-room-body").value'),'@unknown message'); + assert.match(await b.evaluate('document.querySelector("#chat-room-receipt").textContent'),/Repair/); + for(const w of [1440,390]) await shot('room-invalid',w); + assert.equal(await b.evaluate('window.__writes.length'),0); +}); + + +test('granted routes do not expose owner room or remote controls', async t=>{ + const {b}=await setup(t); + await b.evaluate(`MosaicChat.render({path:'/projects/rooms/${P}',container:document.getElementById('bus-view'),canSend:true,user:true})`); + assert.match(await b.evaluate('document.querySelector(".chat-area").textContent'),/Rooms require the business owner/); + assert.equal(await b.evaluate('!!document.querySelector("#chat-room-send-form")'),false); + await b.evaluate(`MosaicChat.render({path:'/projects/${P}/settings',container:document.getElementById('bus-view'),canSend:true,user:true})`); + assert.equal(await b.evaluate('window.__mounts.some(x=>x[0]==="remote")'),false); + assert.equal(await b.evaluate('window.__writes.length'),0); +}); + + +test('pending clear, room receipt and shared history retain truthful state', async t=>{ + const {b,render,wait,shot}=await setup(t); + await render(`/projects/threads/${T}/sessions/${S}`); + await b.evaluate('document.querySelector(".chat-details").open=true;window.__holdClear=true;document.querySelector("#chat-clear").click()'); + await wait('window.__writes.some(w=>w.path.endsWith("/clear"))'); + assert.match(await b.evaluate('document.querySelector(".chat-log").textContent'),/fixture message/); + assert.match(await b.evaluate('document.querySelector("#chat-clear-receipt").textContent'),/requesting clear/); + for(const w of [1440,390]) await shot('clear-pending',w); + await b.evaluate('window.__releaseClear()'); + await render(`/projects/rooms/${P}`); + await b.evaluate('window.__roomAccept=true;document.querySelector("[data-room-member=bob]").click();const e=document.querySelector("#chat-room-body");e.value="@alice hello";e.dispatchEvent(new Event("input",{bubbles:true}));document.querySelector("#chat-room-send-form").dispatchEvent(new Event("submit",{bubbles:true,cancelable:true}))'); + await wait('document.querySelector("#chat-room-receipt").textContent.includes("Accepted")'); + for(const w of [1440,390]) await shot('room-sent',w); + await b.evaluate('window.__shared=true'); + await render(`/projects/threads/${T}`); + assert.match(await b.evaluate('document.querySelector(".chat-area").textContent'),/Read-only thread/); + assert.equal(await b.evaluate('!!document.querySelector("#chat-send-form")'),false); + for(const w of [1440,390]) await shot('shared-readonly',w); +}); + +test('room recipient controls accept keyboard and pointer at both widths', async t=>{ + const {b,render}=await setup(t); + for(const width of [1440,390]) { + await b.viewport(width,1000); await render(`/projects/rooms/${P}`); + await b.evaluate('document.querySelector("[data-room-member=bob]").focus()'); + await b.key(' '); + assert.match(await b.evaluate('document.querySelector("#chat-room-tags").textContent'),/alice/); + assert.equal(await b.evaluate('document.activeElement.dataset.roomMember'),'bob'); + await b.key('Tab'); await b.key('Tab','Tab',8); await b.key('Escape'); + assert.notEqual(await b.evaluate('document.activeElement.tagName'),'BODY'); + const box=await b.evaluate('(()=>{const r=document.querySelector("[data-room-member=bob]").getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()'); + await b.call('Input.dispatchMouseEvent',{type:'mousePressed',x:box.x,y:box.y,button:'left',clickCount:1}); + await b.call('Input.dispatchMouseEvent',{type:'mouseReleased',x:box.x,y:box.y,button:'left',clickCount:1}); + assert.match(await b.evaluate('document.querySelector("#chat-room-tags").textContent'),/bob/); + } +}); From 19e6cb0f1e06e5015e1ceab7a93e676aedc7c7b9 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 5 Nov 2026 21:21:50 -0600 Subject: [PATCH 13/32] fix(cli): address ui797 support review findings (#797) --- BUILD-LOG.md | 2 + docs/SESSIONS.md | 1 + packages/cli/live/README.md | 27 ++++- packages/cli/live/budget-supervisor.mjs | 60 +++++++--- packages/cli/live/budget-supervisor.test.mjs | 118 ++++++++++++++++++- packages/cli/live/isolated-identity.test.mjs | 39 ++++-- packages/cli/live/support-common.mjs | 2 +- packages/cli/live/test-support.mjs | 47 +++++++- packages/cli/live/ui-browser.mjs | 83 +++++++++++-- packages/cli/live/ui-browser.test.mjs | 72 +++++++++-- 10 files changed, 393 insertions(+), 58 deletions(-) diff --git a/BUILD-LOG.md b/BUILD-LOG.md index 1911f6310..11341c2ce 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -6196,3 +6196,5 @@ After: personally authored three-file skills/ms-ux-improvement coordinator packa 2026-11-05 | #797 ui797 support implementation phase | Explicit lead charter from 05fdfef9ea1fdddf58c725c04f229ca8559fc054 on feat/ui797-support: local isolated OIDC/TLS, persistent CDP browser CLI and durable metered campaign supervisor only under packages/cli/live. Queue next refused linked worktree (exit 2); canonical queue/forge ownership remains with lead. Initial author tests identified explicit-config selection and browser evidence-count defects, corrected locally; real browser SSO and full focused gates in progress. No .53/live access, push, runtime/role/contract edits, attribution trailers or stash. 2026-11-05 | #797 ui797 support author candidate verified | Added local single-subject HTTPS OIDC and fixed TLS forwarder, persistent real-input Chromium CLI with private NSS trust, and external durable per-model metered-stop ledgers. Final focused run: 34 pass / 0 fail / 0 skip (identity 6, browser 5, budget 23); dependency regressions: 111 pass / 0 fail / 0 skip; supported processing CLI regressions: 4 pass / 0 fail / 0 skip. Initial configuration-selection, Basic credential decoding, browser readiness/count and shutdown issues were corrected; all final gates pass. Source remains confined to packages/cli/live plus append-only protocol records. Local commit authorized; independent exact-candidate review and supervised-service acceptance remain with lead. No .53/live activity, model spend, push or product-runtime/role/contract changes. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-r1-evidence/; report: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-r1-report.md. + +2026-11-06 | #797 ui797 support R2 review corrections verified | Before: local bd8340b9b accepted unrelated named instances, allowed Chromium WebSocket escape, trusted journal token totals, bypassed halt on lock failure, split model allowances across routes, omitted the absolute cutoff/100M GLM exception, and leaked browser fixtures. After: literal ui797/config/marker validation and persisted campaign revalidation; forced origin-only HTTP proxy (loopback bypass disabled, upgrades refused, HTTPS CONNECT restricted) with real HTTP/HTTPS Chromium negatives and independent zero-TCP/zero-upgrade forbidden-listener assertions; authoritative entry-token comparison/recomputed totals; preserved stale locks with durable halt on interruption/restart/storage failure; chartered proxy GLM/Claude/Codex bindings and one allowance per provider/model; inclusive 2026-11-07T06:00:00.000Z cutoff; exact GLM 100M/other models 10M maxima; awaited reverse-order fixture shutdown with owned-process checks and an intentional-failure whole-file regression. Initial CDP/response-policy browser containment and daemon close-event attempts failed and were corrected; terminal individual whole-file runs are identity 8, browser 8, budget 33 = 49 pass / 0 fail / 0 skip / 0 cancelled. All retained review probes rerun: wrong instance/deadline/split-route refuse, numeric corruption latches/halts, locked checks halt without reclaim, forbidden WebSocket hits 0, SIGTERM/SIGKILL survivors 0. The original general probe now exits 1 at its uncaught, correctly refused split-route initialization; a logging-only companion records every outcome. Final owned-process and 17440/17442/17443 listener sweeps are empty; unrelated existing sessions untouched. Source scope packages/cli/live only plus append-only logs. Queue refused linked worktree; no queue/forge write, push, stash, model call, .53/live contact or product-runtime/role/contract/dependency change. Local author candidate only; independent exact-head approval remains with lead. Evidence/report: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-fix-r2-evidence/ and ui797-support-fix-r2-report.md. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index 4c93312ee..e275c9317 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -2066,3 +2066,4 @@ are never rewritten or removed; corrections are new entries. 2026-11-04 | Mos, skill integrator | #818 / Gitea #1845 | Integrated exact independently approved ms-ux-improvement candidate02 into source;3hashes/validator/whitespace pass and author histories preserved. Separate commit from MVP; its activation target staysbd8120318. No install, activation, publication or live action. 2026-11-05 | Codex ui797 implementation child | #797 isolated OIDC/TLS, browser CLI and campaign budget support from 05fdfef9e on feat/ui797-support | Author implementation/tests underway in lead-chartered worktree; queue refused linked worktree, no canonical queue writes or protected actions. 2026-11-05 | Codex ui797 implementation child | #797 feat/ui797-support author handoff | Three isolated test tools and focused tests ready for lead review; final unique gates 149 pass / 0 fail / 0 skip, real production-CLI browser OIDC sign-in included, metered-stop/ancestry limits documented. Local commit only; no remote/live activity, spend, role/contract or product runtime edits. +2026-11-06 | ui797-support-fix-r2 implementation sub-agent | #797 corrections on feat/ui797-support above bd8340b9b, confined to packages/cli/live plus one appended build entry and this line | All eight review findings addressed with regression tests; sequential whole files identity 8/browser 8/budget 33 (49 pass, 0 fail/skip/cancel), all review probes rerun, owned-process/listener sweeps empty; forced proxy chosen after browser-only containment failed. Local commit authorized; independent review pending. Queue refused linked worktree; no canonical queue/forge writes, push, stash, live/.53 contact, model spend, product-runtime/role/contract/dependency changes or attribution trailers. Report: /mnt/storage/scratch/mosaic-lead/mvp3/ui797-support-fix-r2-report.md. diff --git a/packages/cli/live/README.md b/packages/cli/live/README.md index 568d6637c..61d1a756a 100644 --- a/packages/cli/live/README.md +++ b/packages/cli/live/README.md @@ -121,6 +121,9 @@ The Console validates discovery, JWKS, PKCE, nonce, token signature and its norm session/owner rules. A missing bus, business or ancestry proof still refuses. No human/Console binding shortcut or TLS-verification disable flag is used. Trust `ca.pem` via `NODE_EXTRA_CA_CERTS` only in these test services, never globally. +Prepare and serve require the literal `ui797`, a `mosaic-ui797` config directory +and the current matching data-root marker. Serve revalidates persisted metadata; +the budget commands enforce the same binding. Certificates expire after two days; do not overwrite identity material on restart. ### Step-by-step real browser CLI for tester agents @@ -130,7 +133,14 @@ private Unix-socket daemon. Subsequent invocations reuse that daemon. Either set `UI797_EVIDENCE` or pass `--evidence DIR` on each command. Only the exact loopback origins `http://127.0.0.1:17440` and `https://127.0.0.1:17443` are accepted. Every page/subresource redirect must stay on the selected origin. Other targets and -WebSockets are blocked; a blocked network attempt makes the session fail until +WebSocket connections outside that origin are blocked before the handshake by a +forced local HTTP proxy with Chromium's loopback bypass disabled. It opens sockets +only to the selected origin, refuses HTTP upgrades, and permits CONNECT only to +the selected HTTPS host/port. QUIC and non-proxied WebRTC UDP are disabled. The +installed Chromium's CDP WebSocket blocklist did not contain handshakes in the +negative probe; this external boundary does. Request interception also guards +redirects and subresources. A detected +blocked network attempt makes the session fail until `stop`. The driver never contacts 7332 or mosaic.woltje.com. The browser trusts the test CA through a private NSS database under its evidence @@ -199,7 +209,7 @@ An owner creates a single 0600 `campaign.json` **before launching inference**: "start": "2026-11-06T02:00:00.000Z", "end": "2026-11-06T03:00:00.000Z", "caps": [ - {"provider":"proxy","route":"glm","model":"glm-5.3-flash","tokens":10000000}, + {"provider":"proxy","route":"glm","model":"glm-5.3-flash","tokens":100000000}, {"provider":"proxy","route":"anthropic","model":"claude-opus-5-5","tokens":10000000}, {"provider":"proxy","route":"anthropic","model":"claude-sonnet-5-5","tokens":10000000} ] @@ -208,7 +218,13 @@ An owner creates a single 0600 `campaign.json` **before launching inference**: Use the approved actual absolute config path, canonical UTC campaign start/end and **all** actual model routes; the dates above illustrate the schema. Caps are -positive safe integers at most 10,000,000; tiny values allow zero-spend tests. +positive safe integers: at most 100,000,000 for exactly +`proxy/glm/glm-5.3-flash`, and 10,000,000 for every other approved model. Lower +caps allow zero-spend tests. Only proxy GLM, Claude (`anthropic` route, `claude-*`) +and Codex (`codex` route, `gpt-*`) bindings are accepted. One provider/model has +one allowance across all routes; conflicting bindings refuse. The campaign must +end by **2026-11-07T06:00:00.000Z**; earlier windows narrow that absolute cutoff, +which checks also enforce independently. The test bus store must exist before initialization. Run the supervisor outside model-writable settings/data in a normally supervised owner service: @@ -241,8 +257,13 @@ for its outcome; unknown/malformed completed usage stops the campaign. Each provider/route/model has a separate append-only ledger, durable across rounds/restarts. Campaign bytes, root identity and store identity are pinned; changed caps/epoch, rewritten/disappeared usage and damaged ledger files stop. +Checks recompute each journal entry's tokens and totals from authoritative usage; +a valid-JSON token mismatch durably latches and halts. Initialization is exclusive. There is no reset, raise or resume command. A stale `check.lock` after interruption requires owner diagnosis; never auto-reclaim it. +An existing or uncreatable lock prevents accounting and still asserts the sticky +halt against the verified ui797 binding on every check/serve tick. The lock is +preserved, including when a stop was already latched. A changed target config refuses rather than halting an unverified data root. At any cap, deadline or accounting refusal, the supervisor fsyncs `stop.json` diff --git a/packages/cli/live/budget-supervisor.mjs b/packages/cli/live/budget-supervisor.mjs index 6563d5596..422e3261e 100644 --- a/packages/cli/live/budget-supervisor.mjs +++ b/packages/cli/live/budget-supervisor.mjs @@ -7,6 +7,14 @@ import { join, dirname } from 'node:path'; import { spawnSync } from 'node:child_process'; import { turnRecords } from '../../bus/src/usage.mjs'; import { exclusive, fail, isMain, options, outsideRuntime, plainPath, privateDir, readPrivate, ROOT, testInstance } from './support-common.mjs'; +export const CUTOFF = Date.parse('2026-11-07T06:00:00.000Z'); +const modelIdentity = c => JSON.stringify([c.provider,c.model]); +// The charter permits the GLM identity and Claude/Codex coder model families via proxy. +const approvedBinding = c => c.provider === 'proxy' && ( + (c.route === 'glm' && c.model === 'glm-5.3-flash') || + (c.route === 'anthropic' && /^claude-[a-zA-Z0-9._-]+$/.test(c.model)) || + (c.route === 'codex' && /^gpt-[a-zA-Z0-9._-]+$/.test(c.model))); +const ceiling = c => c.provider === 'proxy' && c.route === 'glm' && c.model === 'glm-5.3-flash' ? 100000000 : 10000000; const kinds = ['input','output','cacheWrite','cacheRead']; const digest = x => createHash('sha256').update(typeof x === 'string' ? x : JSON.stringify(x)).digest('hex'); const identity = c => JSON.stringify([c.provider,c.route,c.model]); @@ -15,16 +23,19 @@ const canonicalTime = s => typeof s === 'string' && Number.isFinite(Date.parse(s function campaignAt(path) { privateDir(dirname(path)); const text = readPrivate(path), c = JSON.parse(text); - if (c.version !== 1 || c.owner !== 'ui797-owner' || !/^[a-z][a-z0-9-]{1,63}$/.test(c.business ?? '') || !canonicalTime(c.start) || !canonicalTime(c.end) || c.end <= c.start || + validateCampaign(c); + return { c, hash:digest(text) }; +} +function validateCampaign(c) { + if (c.instance !== 'ui797' || c.version !== 1 || c.owner !== 'ui797-owner' || !/^[a-z][a-z0-9-]{1,63}$/.test(c.business ?? '') || !canonicalTime(c.start) || !canonicalTime(c.end) || c.end <= c.start || Date.parse(c.end) > CUTOFF || !Array.isArray(c.caps) || !c.caps.length || c.caps.length > 20) fail('campaign-invalid'); const keys = new Set(); for (const cap of c.caps) { if (Object.keys(cap).sort().join(',') !== 'model,provider,route,tokens' || !['proxy','anthropic','zai','openai'].includes(cap.provider) || !['glm','anthropic','codex'].includes(cap.route) || !/^[a-zA-Z0-9][a-zA-Z0-9._:/-]{0,127}$/.test(cap.model ?? '') || - !Number.isSafeInteger(cap.tokens) || cap.tokens < 1 || cap.tokens > 10000000 || keys.has(identity(cap))) fail('campaign-cap-invalid'); - keys.add(identity(cap)); + !approvedBinding(cap) || !Number.isSafeInteger(cap.tokens) || cap.tokens < 1 || cap.tokens > ceiling(cap) || keys.has(modelIdentity(cap))) fail('campaign-cap-invalid'); + keys.add(modelIdentity(cap)); } - return { c, hash:digest(text) }; } function binding(c) { const { system } = testInstance(c.instance,c.config); @@ -119,7 +130,7 @@ function loadLedger(ledger) { if (p.version !== 1 || !p.binding || !p.campaign) fail('ledger-manifest-invalid'); return p; } -export async function check({ campaign,ledger }, { now=Date.now, read=readAccounting, halt=supportedHalt }={}) { +export async function check({ campaign,ledger }, { now=Date.now, read=readAccounting, halt=supportedHalt, acquireLock=mkdirSync }={}) { let p, integrityFailure=false; try { p=loadLedger(ledger); } catch { // With a damaged manifest, only the owner campaign and validated TEST marker may identify @@ -131,20 +142,27 @@ export async function check({ campaign,ledger }, { now=Date.now, read=readAccoun // Pin the target before any halt. A changed config cannot redirect the stop into live data. checkBinding(p.binding,c); outsideRuntime(ledger,{dataRoot:p.binding.dataRoot},c.config); outsideRuntime(campaign,{dataRoot:p.binding.dataRoot},c.config); - const lock=join(ledger,'check.lock'); mkdirSync(lock,{mode:0o700}); + const lock=join(ledger,'check.lock'); let locked=false, lockFailure=false; + try { acquireLock(lock,{mode:0o700}); locked=true; } catch { lockFailure=true; } + // An interrupted/active/unwritable lock prevents accounting, never the verified halt. + // Only the check that created a lock may remove it on normal completion. try { - let reason=integrityFailure?'ledger-manifest-invalid':null, totals=[]; + let reason=integrityFailure?'ledger-manifest-invalid':lockFailure?'check-lock-unavailable':null, totals=[]; const stopFile=join(ledger,'stop.json'); if (existsSync(stopFile)) { reason='stop-already-latched'; try { JSON.parse(readPrivate(stopFile)); } catch { reason='stop-latch-invalid'; } } try { - if (campaignAt(campaign).hash !== p.hash) fail('campaign-changed'); - const clock=now(); if (clock < Date.parse(c.start)) fail('campaign-not-started'); if (clock>=Date.parse(c.end)) fail('campaign-ended'); + const clock=now(); if (!Number.isFinite(clock)) fail('campaign-clock-invalid'); + if (clock>=CUTOFF) fail('campaign-absolute-cutoff'); + validateCampaign(c); + const owner=campaignAt(campaign); + if (owner.hash !== p.hash || JSON.stringify(owner.c) !== JSON.stringify(c)) fail('campaign-changed'); + if (clock < Date.parse(c.start)) fail('campaign-not-started'); if (clock>=Date.parse(c.end)) fail('campaign-ended'); if (!reason) { const records=await read({...p.binding,start:c.start,end:c.end}), observed=new Set(); - const accounts=c.caps.map(cap=>({cap,...journal(ledger,cap)})); + const accounts=c.caps.map(cap=>({cap,...journal(ledger,cap),total:0})); for (const r of records) { if (!canonicalTime(r.at) || r.at!Number.isSafeInteger(r[k]) || r[k]<0)) fail('usage-unknown-or-malformed'); const a=accounts.find(a=>identity(a.cap)===identity(r)); if (!a) fail('usage-uncapped-route-model'); @@ -152,14 +170,23 @@ export async function check({ campaign,ledger }, { now=Date.now, read=readAccoun const tokens=kinds.reduce((sum,k)=>sum+r[k],0); if (!Number.isSafeInteger(tokens)) fail('usage-overflow'); const hash=digest(r), previous=a.entries.get(key); if (previous && previous.digest !== hash) fail('usage-rewritten'); + if (previous && previous.tokens !== tokens) fail('ledger-token-mismatch'); + a.total+=tokens; if (!Number.isSafeInteger(a.total)) fail('ledger-overflow'); if (!previous) { - const e={key,digest:hash,tokens}; writeJournal(join(ledger,filename(a.cap)),e); a.entries.set(key,e); a.total+=tokens; - if (!Number.isSafeInteger(a.total)) fail('ledger-overflow'); + const e={key,digest:hash,tokens}; writeJournal(join(ledger,filename(a.cap)),e); a.entries.set(key,e); } } for (const a of accounts) for (const key of a.entries.keys()) if (!observed.has(key)) fail('usage-disappeared'); totals=accounts.map(a=>({...a.cap,total:a.total})); - if (totals.some(a=>a.total>=a.tokens)) reason='model-cap-reached'; + // Route journals are evidence, never separate allowances for the same model. + const models=new Map(); + for (const a of totals) { + const key=modelIdentity(a), model=models.get(key) ?? {total:0,tokens:Math.min(a.tokens,ceiling(a))}; + model.total+=a.total; model.tokens=Math.min(model.tokens,a.tokens,ceiling(a)); + if (!Number.isSafeInteger(model.total)) fail('ledger-overflow'); + models.set(key,model); + } + if ([...models.values()].some(a=>a.total>=a.tokens)) reason='model-cap-reached'; } } catch(e) { reason=/^[a-z-]{1,80}$/.test(e.message)?e.message:'accounting-failed'; } if (reason) { @@ -179,12 +206,13 @@ export async function check({ campaign,ledger }, { now=Date.now, read=readAccoun return {stopped:true,reason,totals,halt:result}; } return {stopped:false,totals}; - } finally { rmdirSync(lock); } + } finally { if (locked) rmdirSync(lock); } } export function status({ campaign,ledger }) { - const p=loadLedger(ledger); checkBinding(p.binding,p.campaign); + const p=loadLedger(ledger); validateCampaign(p.campaign); checkBinding(p.binding,p.campaign); outsideRuntime(campaign,{dataRoot:p.binding.dataRoot},p.campaign.config); - if (campaignAt(campaign).hash!==p.hash) fail('campaign-changed'); + const owner=campaignAt(campaign); + if (owner.hash!==p.hash || JSON.stringify(owner.c)!==JSON.stringify(p.campaign)) fail('campaign-changed'); const totals=p.campaign.caps.map(cap=>({...cap,total:journal(ledger,cap).total})); return { stopped:existsSync(join(ledger,'stop.json')),end:p.campaign.end,totals }; } diff --git a/packages/cli/live/budget-supervisor.test.mjs b/packages/cli/live/budget-supervisor.test.mjs index 4aa922ab1..438f82b0d 100644 --- a/packages/cli/live/budget-supervisor.test.mjs +++ b/packages/cli/live/budget-supervisor.test.mjs @@ -1,16 +1,19 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { mkdirSync,writeFileSync,readFileSync,readdirSync,unlinkSync,symlinkSync } from 'node:fs'; +import { mkdirSync,writeFileSync,readFileSync,readdirSync,unlinkSync,symlinkSync,renameSync,existsSync,lstatSync } from 'node:fs'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; import { join } from 'node:path'; -import { initialize,check,status,readAccounting,supportedHalt } from './budget-supervisor.mjs'; +import { initialize,check,status,readAccounting,supportedHalt,main,CUTOFF } from './budget-supervisor.mjs'; import { fixture } from './test-support.mjs'; import { Store } from '../../bus/src/store.mjs'; const NOW=Date.parse('2026-11-06T02:30:00.000Z'); const row=(extra={})=>({at:'2026-11-06T02:29:00.000Z',run:'r123456789abc',turn:'t1',source:'transcript',instance:'assistant',provider:'proxy',route:'glm',model:'glm-5.3-flash',status:'known',input:1,output:1,cacheWrite:1,cacheRead:1,...extra}); -function setup(t,tokens=5) { - const f=fixture(t),store=new Store(f.dataRoot);t.after(()=>store.close()); +function setup(t,tokens=5,changes={}) { + const f=fixture(t),store=new Store(f.dataRoot);f.after(()=>store.close()); const campaign=join(f.root,'campaign.json'),ledger=join(f.root,'ledger'); const c={version:1,instance:'ui797',config:f.config,business:'mvp3-ui797',owner:'ui797-owner',start:'2026-11-06T02:00:00.000Z',end:'2026-11-06T03:00:00.000Z',caps:[{provider:'proxy',route:'glm',model:'glm-5.3-flash',tokens},{provider:'proxy',route:'anthropic',model:'claude-opus-5-5',tokens:6}]}; + Object.assign(c,changes); writeFileSync(campaign,JSON.stringify(c),{mode:0o600});initialize({campaign,ledger}); const halts=[],halt=b=>{halts.push(b);return {halted:true,quiescent:true,generation:'test'};}; return {...f,store,campaign,ledger,c,halts,halt,opts:{campaign,ledger}}; @@ -110,7 +113,7 @@ test('an invalid existing stop latch still invokes the supported test halt',asyn const result=await check(f.opts,{now:()=>NOW,read:()=>[],halt:f.halt});assert.equal(result.reason,'stop-latch-invalid');assert.equal(f.halts.length,1); }); test('model-writable campaign paths refuse initialization',t=>{ - const f=fixture(t),store=new Store(f.dataRoot);t.after(()=>store.close()); + const f=fixture(t),store=new Store(f.dataRoot);f.after(()=>store.close()); const campaign=join(f.dataRoot,'campaign.json');writeFileSync(campaign,JSON.stringify({version:1,instance:'ui797',config:f.config,business:'mvp3-ui797',owner:'ui797-owner',start:'2026-11-06T02:00:00.000Z',end:'2026-11-06T03:00:00.000Z',caps:[{provider:'proxy',route:'glm',model:'glm-5.3-flash',tokens:1}]}),{mode:0o600}); assert.throws(()=>initialize({campaign,ledger:join(f.root,'ledger')}),/outside/); }); @@ -120,3 +123,108 @@ test('a failed durable-latch write still halts and returns an explicit storage f await assert.rejects(check(f.opts,{now:()=>NOW,read:()=>[row()],halt:f.halt}),/stop-latch-not-durable/); assert.equal(f.halts.length,1);assert.equal(JSON.parse(readFileSync(join(f.ledger,'halt-1.json'))).storageError,'stop-latch-not-durable'); }); + +test('valid JSON journal token corruption durably latches and halts before accepting another turn',async t=>{ + const f=setup(t),deps={now:()=>NOW,read:()=>[row()],halt:f.halt}; + await check(f.opts,deps); + const file=readdirSync(f.ledger).filter(n=>n.endsWith('.jsonl')).map(n=>join(f.ledger,n)).find(n=>readFileSync(n,'utf8').includes('"key"')); + const lines=readFileSync(file,'utf8').trimEnd().split('\n').map(JSON.parse);lines[1].tokens=0; + writeFileSync(file,lines.map(JSON.stringify).join('\n')+'\n'); + const result=await check(f.opts,{...deps,read:()=>[row(),row({turn:'t2'})]}); + assert.equal(result.reason,'ledger-token-mismatch');assert.equal(f.halts.length,1); + assert.equal(JSON.parse(readFileSync(join(f.ledger,'stop.json'))).reason,'ledger-token-mismatch'); +}); + +test('a check killed while holding its lock is halted on restart without reclaiming the lock',async t=>{ + const f=setup(t,100); + const code=`import {check} from ${JSON.stringify(new URL('./budget-supervisor.mjs',import.meta.url).href)}; await check(${JSON.stringify(f.opts)},{now:()=>${NOW},read:()=>{process.send('locked');return new Promise(()=>{});}});`; + const child=spawn(process.execPath,['--input-type=module','-e',code],{stdio:['ignore','ignore','pipe','ipc']}); + f.after(async()=>{if(child.exitCode===null&&child.signalCode===null){child.kill('SIGKILL');await once(child,'close');}}); + await once(child,'message');child.kill('SIGKILL');await once(child,'close'); + const lock=join(f.ledger,'check.lock'),ino=lstatSync(lock).ino; + const result=await check(f.opts,{now:()=>NOW,read:()=>assert.fail('locked accounting must not run'),halt:f.halt}); + assert.equal(result.reason,'check-lock-unavailable');assert.equal(f.halts.length,1); + assert.equal(lstatSync(lock).ino,ino);assert.ok(existsSync(join(f.ledger,'stop.json'))); +}); + +test('an existing latch reasserts halt on every locked restart and preserves lock evidence',async t=>{ + const f=setup(t,1);await check(f.opts,{now:()=>NOW,read:()=>[row()],halt:f.halt}); + const lock=join(f.ledger,'check.lock');mkdirSync(lock,{mode:0o700});writeFileSync(join(lock,'owner'),'diagnose'); + for(let n=0;n<2;n++) assert.equal((await check(f.opts,{now:()=>NOW,halt:f.halt})).stopped,true); + assert.equal(f.halts.length,3);assert.equal(readFileSync(join(lock,'owner'),'utf8'),'diagnose'); +}); + +test('lock storage failures still durably stop the verified test workload',async t=>{ + for(const code of ['EROFS','EACCES']) { + const f=setup(t,100); + const result=await check(f.opts,{now:()=>NOW,halt:b=>{assert.equal(b.dataRoot,f.dataRoot);assert.ok(existsSync(join(f.ledger,'stop.json')));return f.halt(b);},acquireLock:()=>{throw Object.assign(new Error('storage'),{code});}}); + assert.equal(result.reason,'check-lock-unavailable');assert.equal(f.halts.length,1); + } + const f=setup(t,100),lock=join(f.ledger,'check.lock');writeFileSync(lock,'not a directory',{mode:0o600}); + assert.equal((await check(f.opts,{now:()=>NOW,halt:f.halt})).stopped,true); + assert.equal(readFileSync(lock,'utf8'),'not a directory');assert.equal(f.halts.length,1); +}); + +test('budget init, check, status and serve refuse unrelated instance metadata before accounting or halt',async t=>{ + const f=setup(t,100),other=join(f.root,'mosaic-unrelated');renameSync(join(f.root,'mosaic-ui797'),other); + const config=join(other,'config.json');writeFileSync(join(f.dataRoot,'instance.json'),JSON.stringify({instanceVersion:1,name:'unrelated'})); + f.c.instance='unrelated';f.c.config=config;writeFileSync(f.campaign,JSON.stringify(f.c)); + assert.throws(()=>initialize({campaign:f.campaign,ledger:join(f.root,'other-ledger')}),/campaign-invalid/); + const file=join(f.ledger,'campaign.json'),p=JSON.parse(readFileSync(file));p.campaign=f.c;writeFileSync(file,JSON.stringify(p)); + await assert.rejects(check(f.opts,{now:()=>NOW,halt:f.halt}),/literal ui797/); + assert.throws(()=>status(f.opts),/campaign-invalid/); + await assert.rejects(main(['serve','--campaign',f.campaign,'--ledger',f.ledger]),/literal ui797/); + assert.equal(f.halts.length,0); +}); + +test('persisted campaign metadata is compared to the validated owner manifest before accounting',async t=>{ + const f=setup(t,100),file=join(f.ledger,'campaign.json'),p=JSON.parse(readFileSync(file)); + p.campaign.caps[0].tokens=101;writeFileSync(file,JSON.stringify(p)); + assert.equal((await check(f.opts,{now:()=>NOW,read:()=>assert.fail('changed metadata'),halt:f.halt})).reason,'campaign-changed'); + assert.equal(f.halts.length,1); +}); + +test('absolute cutoff is inclusive; later windows refuse and a persisted extension cannot delay a stop',async t=>{ + const end='2026-11-07T06:00:00.000Z',f=setup(t,100,{end}); + assert.equal((await check(f.opts,{now:()=>CUTOFF-1,read:()=>[],halt:f.halt})).stopped,false); + assert.equal((await check(f.opts,{now:()=>CUTOFF,read:()=>[],halt:f.halt})).reason,'campaign-absolute-cutoff'); + assert.equal(f.halts.length,1); + assert.throws(()=>setup(t,100,{end:'2026-11-08T06:00:00.000Z'}),/campaign-invalid/); + const g=setup(t,100,{end}),file=join(g.ledger,'campaign.json'),p=JSON.parse(readFileSync(file)); + p.campaign.end='2026-11-08T06:00:00.000Z';writeFileSync(file,JSON.stringify(p)); + assert.equal((await check(g.opts,{now:()=>CUTOFF,halt:g.halt})).reason,'campaign-absolute-cutoff'); + assert.equal(g.halts.length,1); +}); + +test('only the exact proxy/glm identity receives 100M; other approved models remain capped at 10M',t=>{ + const cap=(provider,route,model,tokens)=>({provider,route,model,tokens}); + const glm=cap('proxy','glm','glm-5.3-flash',100000000),claude=cap('proxy','anthropic','claude-opus-5-5',10000000),codex=cap('proxy','codex','gpt-6-astra',10000000); + for(const c of [glm,claude,codex])assert.ok(setup(t,1,{caps:[c]})); + for(const c of [glm,claude,codex])assert.throws(()=>setup(t,1,{caps:[{...c,tokens:c.tokens+1}]}),/campaign-cap-invalid/); + for(const c of [cap('zai','glm','glm-5.3-flash',100000000),cap('proxy','anthropic','glm-5.3-flash',100000000),cap('proxy','glm','claude-opus-5-5',100000000)])assert.throws(()=>setup(t,1,{caps:[c]}),/campaign-cap-invalid/); +}); + +test('conflicting route bindings refuse initialization and real-store split-route usage stops',async t=>{ + const cap={provider:'proxy',route:'anthropic',model:'claude-opus-5-5',tokens:10000000}; + assert.throws(()=>setup(t,1,{caps:[{...cap,route:'glm'},cap]}),/campaign-cap-invalid/); + assert.throws(()=>setup(t,1,{caps:[cap,{...cap}]}),/campaign-cap-invalid/); + const f=setup(t,1,{caps:[cap]}); + for(const [i,route] of ['anthropic','glm'].entries()) { + const r=row({run:i?'r123456789abd':'r123456789abc',turn:`t${i}`,model:cap.model,input:6000000,output:0,cacheWrite:0,cacheRead:0}); + f.store.run('INSERT INTO usage_records(at,business,run,instance,provider,model,turn,source,phase,status,input,output,cache_write,cache_read) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)',r.at,f.c.business,r.run,r.instance,r.provider,r.model,r.turn,r.source,'end','known',r.input,0,0,0); + const dir=join(f.dataRoot,'launches',f.c.business,r.run,'bundle');mkdirSync(dir,{recursive:true,mode:0o700}); + writeFileSync(join(dir,'manifest.json'),JSON.stringify({business:f.c.business,run:r.run,instance:r.instance,provider:r.provider,model:r.model,proxyRoute:route,route:'primary'}),{mode:0o600}); + } + const result=await check(f.opts,{now:()=>NOW,halt:f.halt}); + assert.equal(result.reason,'usage-uncapped-route-model');assert.equal(f.halts.length,1); + assert.ok(existsSync(join(f.ledger,'stop.json'))); +}); + +test('the authorized GLM and other-model maxima halt at the exact charged ceiling',async t=>{ + for(const cap of [{provider:'proxy',route:'glm',model:'glm-5.3-flash',tokens:100000000},{provider:'proxy',route:'anthropic',model:'claude-sonnet-5-5',tokens:10000000}]) { + const f=setup(t,1,{caps:[cap]}); + const r=row({...cap,input:cap.tokens,output:0,cacheWrite:0,cacheRead:0});delete r.tokens; + const result=await check(f.opts,{now:()=>NOW,read:()=>[r],halt:f.halt}); + assert.equal(result.reason,'model-cap-reached');assert.equal(result.totals[0].total,cap.tokens);assert.equal(f.halts.length,1); + } +}); diff --git a/packages/cli/live/isolated-identity.test.mjs b/packages/cli/live/isolated-identity.test.mjs index 0647a173b..fe65ccc4f 100644 --- a/packages/cli/live/isolated-identity.test.mjs +++ b/packages/cli/live/isolated-identity.test.mjs @@ -3,12 +3,11 @@ import assert from 'node:assert/strict'; import { createHash, verify, createPublicKey } from 'node:crypto'; import { request } from 'node:https'; import { createServer } from 'node:http'; -import { readFileSync, statSync, writeFileSync } from 'node:fs'; +import { readFileSync, statSync, writeFileSync,renameSync } from 'node:fs'; import { join } from 'node:path'; import { spawn } from 'node:child_process'; -import { once } from 'node:events'; import { prepare,serve,SUBJECT } from './isolated-identity.mjs'; -import { fixture } from './test-support.mjs'; +import { fixture,closeServer,closeChild } from './test-support.mjs'; import { readSlot } from '../../webui/src/sso.mjs'; function setup(t) { const f=fixture(t),runtime=join(f.root,'identity'); @@ -43,7 +42,7 @@ test('live/default instances, wrong owner, unclaimed root and unsafe config path assert.throws(()=>prepare(defaults)); }); test('real HTTPS authorization redirects, PKCE and nonce bind a single-use bounded RS256 token',async t=>{ - const f=setup(t),server=await serve(f.runtime);t.after(()=>{server.closeAllConnections();server.close();}); + const f=setup(t),server=await serve(f.runtime);f.after(()=>closeServer(server)); const discovery=await http(f.runtime,'/oidc/.well-known/openid-configuration');assert.equal(discovery.status,200); const verifier='a'.repeat(43),challenge=createHash('sha256').update(verifier).digest('base64url'); const q=new URLSearchParams({client_id:'mosaic-ui797',redirect_uri:'https://127.0.0.1:17443/auth/callback',response_type:'code',scope:'openid',state:'test-state',nonce:'test-nonce',code_challenge:challenge,code_challenge_method:'S256'}); @@ -67,23 +66,23 @@ test('real HTTPS authorization redirects, PKCE and nonce bind a single-use bound test('fixed TLS forwarder preserves normal Console Host, cookies and origin without injected identity',async t=>{ const f=setup(t);let headers; const upstream=createServer((req,res)=>{headers=req.headers;res.end('Console upstream');}); - await new Promise((resolve,reject)=>{upstream.once('error',reject);upstream.listen(17440,'127.0.0.1',resolve);});t.after(()=>upstream.close()); - const server=await serve(f.runtime);t.after(()=>{server.closeAllConnections();server.close();}); + await new Promise((resolve,reject)=>{upstream.once('error',reject);upstream.listen(17440,'127.0.0.1',resolve);});f.after(()=>closeServer(upstream)); + const server=await serve(f.runtime);f.after(()=>closeServer(server)); const r=await http(f.runtime,'/test',{headers:{origin:'https://127.0.0.1:17443',cookie:'fixture=value'}}); assert.equal(r.body,'Console upstream');assert.equal(headers.host,'127.0.0.1:17443');assert.equal(headers.origin,'https://127.0.0.1:17443');assert.equal(headers.cookie,'fixture=value');assert.equal(headers['x-auth-subject'],undefined); }); test('production Console CLI discovers this OP through NODE_EXTRA_CA_CERTS with ordinary TLS validation',async t=>{ const f=setup(t),config=JSON.parse(readFileSync(f.config));config.console=f.output.console;writeFileSync(f.config,JSON.stringify(config)); - const server=await serve(f.runtime);t.after(()=>{server.closeAllConnections();server.close();}); + const server=await serve(f.runtime);f.after(()=>closeServer(server)); const child=spawn(process.execPath,['packages/webui/src/cli.mjs','serve','--port','17440','--board','http://127.0.0.1:17439','--sso'],{env:{...process.env,MOSAIC_CONFIG:f.config,NODE_EXTRA_CA_CERTS:join(f.runtime,'ca.pem')},stdio:['ignore','pipe','pipe']}); - t.after(async()=>{if(child.exitCode===null){child.kill('SIGTERM');await once(child,'exit');}}); + f.after(async()=>{await closeChild(child);}); let output='';child.stdout.on('data',c=>output+=c);child.stderr.on('data',c=>output+=c); await new Promise((resolve,reject)=>{const timer=setTimeout(()=>reject(new Error('Console startup: '+output)),15000);child.stdout.on('data',()=>{if(output.includes('http://127.0.0.1:17440')){clearTimeout(timer);resolve();}});child.once('exit',()=>{clearTimeout(timer);reject(new Error('Console refused: '+output));});}); const health=JSON.parse((await http(f.runtime,'/healthz')).body);assert.equal(health.sso['ui797-local'],'ready'); }); test('authorization codes expire, and tampering with the single subject refuses service',async t=>{ const f=setup(t);let now=Date.now(); - const server=await serve(f.runtime,{clock:()=>now});t.after(()=>{server.closeAllConnections();server.close();}); + const server=await serve(f.runtime,{clock:()=>now});f.after(()=>closeServer(server)); const verifier='v'.repeat(43),challenge=createHash('sha256').update(verifier).digest('base64url'); const q=new URLSearchParams({client_id:'mosaic-ui797',redirect_uri:'https://127.0.0.1:17443/auth/callback',response_type:'code',scope:'openid',state:'state',nonce:'nonce',code_challenge:challenge,code_challenge_method:'S256'}); const code=new URL((await http(f.runtime,'/oidc/authorize?'+q)).headers.location).searchParams.get('code');now+=60001; @@ -93,3 +92,25 @@ test('authorization codes expire, and tampering with the single subject refuses const stateFile=join(f.runtime,'identity.json'),state=JSON.parse(readFileSync(stateFile));state.console.sso.providers[0].subjects=[{sub:'jason',name:'jason'}];writeFileSync(stateFile,JSON.stringify(state)); await assert.rejects(serve(f.runtime),/metadata refused/); }); + +test('prepare and persisted serve metadata refuse an otherwise valid unrelated development instance',async t=>{ + const f=setup(t),other=join(f.root,'mosaic-unrelated'); + renameSync(join(f.root,'mosaic-ui797'),other); + const config=join(other,'config.json'); + writeFileSync(join(f.dataRoot,'instance.json'),JSON.stringify({instanceVersion:1,name:'unrelated'})); + assert.throws(()=>prepare({instance:'unrelated',config,owner:'ui797-owner',runtime:join(f.root,'other-id'),tlsPort:17443}),/literal ui797/); + const stateFile=join(f.runtime,'identity.json'),state=JSON.parse(readFileSync(stateFile)); + state.instance='unrelated';state.config=config;writeFileSync(stateFile,JSON.stringify(state)); + await assert.rejects(serve(f.runtime),/literal ui797/); +}); + +test('serve revalidates the config directory and current data marker before listening',async t=>{ + const f=setup(t); + writeFileSync(join(f.dataRoot,'instance.json'),JSON.stringify({instanceVersion:1,name:'unrelated'})); + await assert.rejects(serve(f.runtime),/marker mismatch/); + writeFileSync(join(f.dataRoot,'instance.json'),JSON.stringify({instanceVersion:1,name:'ui797'})); + const stateFile=join(f.runtime,'identity.json'),state=JSON.parse(readFileSync(stateFile)); + const other=join(f.root,'mosaic-unrelated');renameSync(join(f.root,'mosaic-ui797'),other); + state.config=join(other,'config.json');writeFileSync(stateFile,JSON.stringify(state)); + await assert.rejects(serve(f.runtime),/matching named test config/); +}); diff --git a/packages/cli/live/support-common.mjs b/packages/cli/live/support-common.mjs index b741d4343..581bd06f4 100644 --- a/packages/cli/live/support-common.mjs +++ b/packages/cli/live/support-common.mjs @@ -48,7 +48,7 @@ export function options(argv, allowed) { return out; } export function testInstance(instance, config) { - if (!/^[a-z][a-z0-9]{1,15}$/.test(instance ?? '') || ['dev','prod','production'].includes(instance)) fail('explicit named test instance required'); + if (instance !== 'ui797') fail('literal ui797 test instance required'); plainPath(config); const copies = join(process.env.XDG_DATA_HOME || join(homedir(), '.local/share'), 'mosaic-stack/copies'); if (inside(ROOT, config) || inside(copies, config)) fail('test config and secret slots must be outside source and stack copies'); diff --git a/packages/cli/live/test-support.mjs b/packages/cli/live/test-support.mjs index 5b2e20b2b..9529c3d3d 100644 --- a/packages/cli/live/test-support.mjs +++ b/packages/cli/live/test-support.mjs @@ -1,15 +1,56 @@ // Focused test fixtures only; runtime CLIs never import this file. -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync,readFileSync,readdirSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; export function fixture(t) { if (!process.env.TMPDIR?.startsWith('/mnt/storage/scratch/tmp/') || process.env.TMPDIR.includes('_')) throw new Error('set TMPDIR with mktemp -d /mnt/storage/scratch/tmp/XXXXXX'); const root=mkdtempSync(join(tmpdir(),'ui797-')); - t.after(()=>rmSync(root,{recursive:true,force:true})); + const cleanups=[]; + t.after(async()=>{ + const owned=ownedProcesses(root); + const errors=[]; + for(const cleanup of cleanups.reverse()) try { await cleanup(); } catch(e) { errors.push(e); } + // Preserve evidence if resource shutdown failed; never delete beneath a running service. + const survivors=owned.filter(p=>processAlive(p)); + if(survivors.length) errors.push(new Error(`owned processes survived fixture shutdown: ${survivors.map(p=>p.pid)}`)); + if(errors.length) throw new AggregateError(errors,'fixture shutdown failed'); + rmSync(root,{recursive:true,force:true}); + }); const configDir=join(root,'mosaic-ui797'),dataRoot=join(root,'data'); mkdirSync(configDir,{mode:0o700});mkdirSync(dataRoot,{mode:0o700}); const config=join(configDir,'config.json'); writeFileSync(config,JSON.stringify({configVersion:1,environment:'development',dataRoot,execution:{backend:'docker',provider:'zai',model:'glm-5.3-flash'}})); writeFileSync(join(dataRoot,'instance.json'),JSON.stringify({instanceVersion:1,name:'ui797'})); - return {root,config,dataRoot}; + return {root,config,dataRoot,after:fn=>cleanups.push(fn)}; +} + +export async function closeServer(server) { + const closed=new Promise((resolve,reject)=>server.close(e=>e?reject(e):resolve())); + server.closeAllConnections(); + await closed; +} +export async function closeChild(child) { + if(child.exitCode!==null || child.signalCode!==null) return; + const closed=new Promise(resolve=>child.once('close',resolve)); + child.kill('SIGTERM'); + await closed; +} + +// Snapshot only this fixture's processes, including reparented Chromium crash handlers. +export function ownedProcesses(root) { + const processes=readdirSync('/proc').filter(n=>/^\d+$/.test(n)).flatMap(pid=>{ + try { + const args=readFileSync(`/proc/${pid}/cmdline`,'utf8').split('\0'); + const stat=readFileSync(`/proc/${pid}/stat`,'utf8').split(' '); + return [{pid:Number(pid),ppid:Number(stat[3]),start:stat[21],args}]; + } catch { return []; } + }); + const selected=new Set(processes.filter(p=>p.args.some(a=>a===root || a.startsWith(root+'/') || a.startsWith('--user-data-dir='+root+'/'))).map(p=>p.pid)); + for(const p of processes) if(selected.has(p.pid)) for(const a of p.args) if(a.startsWith('--crashpad-handler-pid=')) selected.add(Number(a.split('=')[1])); + let size; + do {size=selected.size;for(const p of processes) if(selected.has(p.ppid))selected.add(p.pid);} while(size!==selected.size); + return processes.filter(p=>selected.has(p.pid)); +} +export function processAlive(p) { + try { const stat=readFileSync(`/proc/${p.pid}/stat`,'utf8').split(' ');return stat[21]===p.start && stat[2]!=='Z'; } catch {return false;} } diff --git a/packages/cli/live/ui-browser.mjs b/packages/cli/live/ui-browser.mjs index d3d421d17..dff554d56 100644 --- a/packages/cli/live/ui-browser.mjs +++ b/packages/cli/live/ui-browser.mjs @@ -2,6 +2,7 @@ // Persistent CDP pipe client, based on webui/tests/browser.mjs. No injected app script. import { spawn, fork, spawnSync } from 'node:child_process'; import { createServer, createConnection } from 'node:net'; +import {createServer as httpServer,request as httpRequest} from 'node:http'; import { once } from 'node:events'; import { createHash, X509Certificate } from 'node:crypto'; import { appendFileSync, chmodSync, existsSync, readdirSync, readFileSync, unlinkSync } from 'node:fs'; @@ -9,6 +10,7 @@ import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { homedir } from 'node:os'; import { exclusive, fail, isMain, options, plainPath, privateDir, readPrivate } from './support-common.mjs'; +const ownedDaemons = new Map(); const HERE = fileURLToPath(import.meta.url); const INTERACTIVE = new Set(['button','link','textbox','searchbox','combobox','checkbox','radio','switch','tab','menuitem','option','spinbutton','slider']); const delay = ms => new Promise(r => setTimeout(r, ms)); @@ -19,7 +21,7 @@ export function testURL(value, origin = null) { if (origin && u.origin !== origin) fail('navigation must stay on the selected test origin'); return u; } -async function chromium(evidence, ca) { +async function chromium(evidence, ca, proxyPort) { const cert = new X509Certificate(readPrivate(ca)); if (!cert.ca || Date.parse(cert.validTo) <= Date.now() || Date.parse(cert.validFrom) > Date.now()) fail('valid private test CA required'); const xdg = join(evidence, 'xdg'); privateDir(xdg, true); @@ -39,7 +41,9 @@ async function chromium(evidence, ca) { if (legacy) plainPath(legacyNss); const executable = process.env.CHROMIUM || '/usr/bin/chromium'; const chromeArgs = ['--headless','--no-first-run','--no-default-browser-check','--disable-dev-shm-usage', - '--disable-background-networking','--disable-component-update','--disable-sync','--no-proxy-server','--remote-debugging-pipe', + '--disable-background-networking','--disable-component-update','--disable-sync','--disable-quic', + `--proxy-server=http://127.0.0.1:${proxyPort}`,'--proxy-bypass-list=<-loopback>', + '--force-webrtc-ip-handling-policy=disable_non_proxied_udp','--remote-debugging-pipe', `--user-data-dir=${profile}`]; const child = spawn(legacy ? '/usr/bin/bwrap' : executable, legacy ? ['--die-with-parent','--bind','/','/','--dev-bind','/dev','/dev','--proc','/proc','--bind',nss,legacyNss,'--',executable,...chromeArgs] : chromeArgs, { env:{...process.env,XDG_DATA_HOME:xdg}, stdio: ['ignore','ignore','ignore','pipe','pipe'] }); @@ -66,17 +70,58 @@ async function chromium(evidence, ca) { const { targetId } = await send('Target.createTarget', { url:'about:blank' }); const { sessionId } = await send('Target.attachToTarget', { targetId, flatten:true }); const call = (method, params) => send(method, params, sessionId); - return { call, send, on, child, async close() { - await send('Browser.close').catch(() => {}); - if (child.exitCode === null) await once(child, 'exit'); + let closing; + return { call, send, on, child, close() { + return closing ??= (async () => { + const exited=once(child,'close'); + await send('Browser.close').catch(() => {}); + if (child.exitCode === null && child.signalCode === null) await exited; + })(); } }; } +// External transport boundary: Chromium must use this proxy even for loopback. +// It never resolves/opens a socket for an unapproved destination. CDP is defense in depth. +async function originProxy(origin) { + const allowed=new URL(origin),sockets=new Set(); + const track=socket=>{sockets.add(socket);socket.once('close',()=>sockets.delete(socket));return socket;}; + const server=httpServer((req,res)=>{ + let url; + try {url=testURL(req.url,origin);if(url.origin!==origin || allowed.protocol!=='http:')throw Error();} + catch {res.writeHead(403);res.end();return;} + const upstream=httpRequest({hostname:'127.0.0.1',port:Number(allowed.port),method:req.method,path:url.pathname+url.search,headers:req.headers},reply=>{ + res.writeHead(reply.statusCode,reply.headers);reply.pipe(res); + }); + upstream.on('socket',track); + upstream.on('error',()=>{if(!res.headersSent)res.writeHead(502);res.end();}); + req.on('aborted',()=>upstream.destroy());req.pipe(upstream); + }); + server.on('connection',track); + server.on('upgrade',(_req,socket)=>socket.destroy()); + server.on('connect',(req,peer,head)=>{ + // Only TLS to the selected HTTPS origin may tunnel. WS/other-origin tunnels refuse. + if(allowed.protocol!=='https:' || req.url!==allowed.host){peer.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');return;} + const upstream=track(createConnection({host:'127.0.0.1',port:Number(allowed.port)})); + upstream.once('connect',()=>{peer.write('HTTP/1.1 200 Connection Established\r\n\r\n');if(head.length)upstream.write(head);peer.pipe(upstream);upstream.pipe(peer);}); + upstream.on('error',()=>peer.destroy());peer.on('error',()=>upstream.destroy()); + peer.once('close',()=>upstream.destroy());upstream.once('close',()=>peer.destroy()); + }); + await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',resolve);}); + let closing; + return {port:server.address().port,close(){return closing??=(async()=>{ + const closed=new Promise(resolve=>server.close(resolve));for(const socket of sockets)socket.destroy();await closed; + })();}}; +} export async function daemon(evidence) { privateDir(evidence); const state = JSON.parse(readPrivate(join(evidence, 'browser.json'))), origin = testURL(state.origin).origin; - const b = await chromium(evidence, state.ca); + let blocked=false; + const proxy=await originProxy(origin); + let b; try { - let sequence = 0, generation = 0, refs = new Map(), blocked = false, detail = {}; + b = await chromium(evidence, state.ca,proxy.port); + const closeChrome=b.close; + b.close=async()=>{try{await closeChrome();}finally{await proxy.close();}}; + let sequence = 0, generation = 0, refs = new Map(), detail = {}; const completed = new Map(), pendingDownloads = new Set(); const downloadsDir = join(evidence,'downloads'); privateDir(downloadsDir, true); b.on('Browser.downloadWillBegin', e => { pendingDownloads.add(e.guid); }); @@ -99,6 +144,11 @@ export async function daemon(evidence) { await b.send('Target.setAutoAttach', { autoAttach:true, waitForDebuggerOnStart:true, flatten:true }); await b.call('Page.enable'); await b.call('Accessibility.enable'); await b.call('DOM.enable'); await b.call('Network.enable'); await b.call('Network.setBlockedURLs', { urls:['ws://*','wss://*'] }); + // Detection latches the session; the forced proxy enforces BEFORE outbound connection. + // Chromium's own background probes are also refused by the proxy, without poisoning UI actions. + b.on('Network.webSocketCreated',e=>{ + try {testURL(e.url.replace(/^ws/,'http'),origin);} catch {blocked=true;} + }); await b.call('Fetch.enable', { patterns:[{ urlPattern:'*', requestStage:'Request' }] }); await b.send('Browser.setDownloadBehavior', { behavior:'allowAndName', downloadPath:downloadsDir, eventsEnabled:true }); await b.call('Emulation.setDeviceMetricsOverride', { width:1280, height:1000, deviceScaleFactor:1, mobile:false }); @@ -238,32 +288,41 @@ export async function daemon(evidence) { const cleanup = async () => { server.close(); await b.close(); if (existsSync(socket)) unlinkSync(socket); }; for (const signal of ['SIGTERM','SIGINT']) process.once(signal, () => cleanup().finally(() => process.exit(0))); return { server, cleanup }; - } catch (e) { await b.close().catch(() => {}); throw e; } + } catch (e) { await b?.close().catch(() => {}); await proxy.close(); throw e; } } -export function callBrowser(evidence, command, args=[]) { +export async function callBrowser(evidence, command, args=[]) { privateDir(evidence); readPrivate(join(evidence,'browser.json')); - return new Promise((resolve,reject) => { + const value=await new Promise((resolve,reject) => { const peer = createConnection(join(evidence,'browser.sock')); let data = ''; peer.on('error', () => {}); peer.setTimeout(150000, () => peer.destroy(new Error('browser action timed out; inspect receipts before retry'))); peer.on('error',reject); peer.on('connect', () => peer.write(JSON.stringify({ command,args })+'\n')); peer.on('data',chunk => { data += chunk; }); peer.on('end', () => { try { const r = JSON.parse(data); r.ok ? resolve(r.value) : reject(new Error(r.error)); } catch(e) { reject(e); } }); }); + if (command === 'stop' && ownedDaemons.has(evidence)) await ownedDaemons.get(evidence).exited; + return value; } export async function start({ evidence, origin, ca }) { if (testURL(origin).origin !== origin) fail('origin must have no path, query or fragment'); plainPath(ca); readPrivate(ca); plainPath(evidence); privateDir(evidence,true); exclusive(join(evidence,'browser.json'),JSON.stringify({ version:1,origin,ca })+'\n'); const child = fork(HERE,['--daemon',evidence],{ detached:true,stdio:['ignore','ignore','ignore','ipc'] }); + const exited=new Promise(resolve=>child.once('exit',resolve)); + ownedDaemons.set(evidence,{child,exited}); + exited.then(()=>ownedDaemons.delete(evidence)); try { await new Promise((resolve,reject) => { const timer = setTimeout(() => { child.kill('SIGTERM'); reject(new Error('browser daemon startup timed out')); },20000); child.once('error',reject); child.once('exit', () => { clearTimeout(timer); reject(new Error('browser daemon failed to start')); }); child.once('message',m => { clearTimeout(timer); m.ok ? resolve() : reject(new Error(m.error)); }); }); - } catch (e) { child.kill('SIGTERM'); if (child.connected) child.disconnect(); child.unref(); throw e; } + } catch (e) { child.kill('SIGTERM'); if (child.connected) child.disconnect(); await exited; throw e; } child.disconnect(); child.unref(); - return callBrowser(evidence,'start'); + try { return await callBrowser(evidence,'start'); } + catch(e) { + await callBrowser(evidence,'stop').catch(async()=>{child.kill('SIGTERM');await exited;}); + throw e; + } } export async function main(argv, env=process.env) { const command = argv.shift(); diff --git a/packages/cli/live/ui-browser.test.mjs b/packages/cli/live/ui-browser.test.mjs index cd37efecf..a693fcfda 100644 --- a/packages/cli/live/ui-browser.test.mjs +++ b/packages/cli/live/ui-browser.test.mjs @@ -1,12 +1,12 @@ -import test from 'node:test'; +import test,{after} from 'node:test'; import assert from 'node:assert/strict'; import { createServer } from 'node:http'; -import { readFileSync, readdirSync, existsSync } from 'node:fs'; +import { readFileSync, readdirSync, existsSync,writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { spawn } from 'node:child_process'; import { start,callBrowser,testURL } from './ui-browser.mjs'; import { prepare,serve } from './isolated-identity.mjs'; -import { fixture } from './test-support.mjs'; +import { fixture,closeServer,closeChild,ownedProcesses } from './test-support.mjs'; const pause=ms=>new Promise(r=>setTimeout(r,ms)); test('browser URL guard refuses live, remote, credentials, non-test ports and origin changes',()=>{ for(const u of ['http://127.0.0.1:7332','https://mosaic.woltje.com','http://10.1.1.53:17440','http://localhost:17440','http://u:p@127.0.0.1:17440','https://127.0.0.1:17440','http://127.0.0.1:17443'])assert.throws(()=>testURL(u)); @@ -23,9 +23,9 @@ async function setup(t,html) { if(req.url==='/redirect'){res.writeHead(302,{location:'http://127.0.0.1:7332/live'});res.end();return;} res.setHeader('content-type','text/html');res.end(html); }); - await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(17440,'127.0.0.1',resolve);});t.after(()=>server.close()); + await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(17440,'127.0.0.1',resolve);});f.after(()=>closeServer(server)); const evidence=join(f.root,'browser'); - t.after(async()=>{if(existsSync(join(evidence,'browser.sock')))await callBrowser(evidence,'stop').catch(()=>{});}); + f.after(async()=>{if(existsSync(join(evidence,'browser.sock')))await callBrowser(evidence,'stop');}); return {...f,identity,evidence,hits}; } test('real Chromium keeps a session across CLI processes; mouse/keyboard refs, screenshots and actual downloads',async t=>{ @@ -70,9 +70,10 @@ test('private CA browser performs the actual authorization redirect into product const f=fixture(t),identity=join(f.root,'id'),evidence=join(f.root,'browser'); const output=prepare({instance:'ui797',config:f.config,owner:'ui797-owner',runtime:identity,tlsPort:17443}); const config=JSON.parse(readFileSync(f.config));config.console=output.console;writeFileSync(f.config,JSON.stringify(config)); - const idp=await serve(identity);t.after(()=>{idp.closeAllConnections();idp.close();}); + const idp=await serve(identity);f.after(()=>closeServer(idp)); const child=spawn(process.execPath,['packages/webui/src/cli.mjs','serve','--port','17440','--board','http://127.0.0.1:17439','--sso'],{env:{...process.env,MOSAIC_CONFIG:f.config,NODE_EXTRA_CA_CERTS:join(identity,'ca.pem')},stdio:['ignore','pipe','pipe']});let text='';child.stdout.on('data',c=>text+=c);child.stderr.on('data',c=>text+=c); - t.after(async()=>{if(existsSync(join(evidence,'browser.sock')))await callBrowser(evidence,'stop').catch(()=>{});if(child.exitCode===null){child.kill('SIGTERM');await new Promise(r=>child.once('exit',r));}}); + f.after(()=>closeChild(child)); + f.after(async()=>{if(existsSync(join(evidence,'browser.sock')))await callBrowser(evidence,'stop');}); await new Promise((resolve,reject)=>{const timer=setTimeout(()=>reject(new Error(text)),15000);child.stdout.on('data',()=>{if(text.includes('http://127.0.0.1:17440')){clearTimeout(timer);resolve();}});child.once('exit',()=>{clearTimeout(timer);reject(new Error(text));});}); const initial=await start({evidence,origin:'https://127.0.0.1:17443',ca:join(identity,'ca.pem')}); // The sole provider signs in immediately through redirects during start. @@ -84,8 +85,61 @@ test('private CA browser performs the actual authorization redirect into product test('a different private CA is refused by Chromium with TLS verification enabled',async t=>{ const f=fixture(t),g=fixture(t),id=join(f.root,'id'),other=join(g.root,'id'),evidence=join(f.root,'browser'); for(const [config,runtime] of [[f.config,id],[g.config,other]])prepare({instance:'ui797',config,owner:'ui797-owner',runtime,tlsPort:17443}); - const server=await serve(other);t.after(()=>{server.closeAllConnections();server.close();}); - t.after(async()=>{if(existsSync(join(evidence,'browser.sock')))await callBrowser(evidence,'stop');}); + const server=await serve(other);f.after(()=>closeServer(server)); + f.after(async()=>{if(existsSync(join(evidence,'browser.sock')))await callBrowser(evidence,'stop');}); await assert.rejects(start({evidence,origin:'https://127.0.0.1:17443',ca:join(id,'ca.pem')}),/navigation failed/); assert.ok(readFileSync(join(evidence,'actions.jsonl'),'utf8').includes('false')); }); + +for(const origin of ['http://127.0.0.1:17440','https://127.0.0.1:17443'])test(`Chromium ${origin} blocks WebSocket handshakes before the forbidden listener sees any TCP connection`,async t=>{ + let connections=0,upgrades=0; + const outside=createServer((_req,res)=>res.end('forbidden')); + outside.on('connection',()=>connections++); + outside.on('upgrade',(_req,socket)=>{upgrades++;socket.destroy();}); + await new Promise((resolve,reject)=>{outside.once('error',reject);outside.listen(17442,'127.0.0.1',resolve);}); + t.after(()=>closeServer(outside)); + const f=await setup(t,`

        safe

        `); + if(origin.startsWith('https:')) {const server=await serve(f.identity);f.after(()=>closeServer(server));} + await start({evidence:f.evidence,origin,ca:join(f.identity,'ca.pem')}); + await assert.rejects(callBrowser(f.evidence,'click',['--text','WebSocket']),/blocked/); + await pause(300); + assert.equal(connections,0);assert.equal(upgrades,0); + await callBrowser(f.evidence,'stop'); +}); + +test('a failing whole test file shuts down its browser and servers before removing fixture roots',async t=>{ + const f=fixture(t),file=join(f.root,'failure.test.mjs'); + const url=name=>JSON.stringify(new URL(name,import.meta.url).href); + writeFileSync(file,`import test from 'node:test'; +import {createServer} from 'node:http'; +import {join} from 'node:path'; +import {fixture,closeServer} from ${url('./test-support.mjs')}; +import {prepare} from ${url('./isolated-identity.mjs')}; +import {start,callBrowser} from ${url('./ui-browser.mjs')}; +test('intentional failure after browser startup',async t=>{ + const f=fixture(t),identity=join(f.root,'id'),evidence=join(f.root,'browser'); + console.log('FIXTURE_ROOT='+f.root); + prepare({instance:'ui797',config:f.config,owner:'ui797-owner',runtime:identity,tlsPort:17443}); + const server=createServer((req,res)=>res.end('

        failure cleanup

        ')); + await new Promise(r=>server.listen(17440,'127.0.0.1',r));f.after(()=>closeServer(server)); + f.after(()=>callBrowser(evidence,'stop')); + await start({evidence,origin:'http://127.0.0.1:17440',ca:join(identity,'ca.pem')}); + throw new Error('expected-cleanup-failure'); +});`); + const child=spawn(process.execPath,['--test','--test-reporter=tap',file],{env:{...process.env,NODE_TEST_CONTEXT:undefined},stdio:['ignore','pipe','pipe']}); + f.after(()=>closeChild(child)); + let output='';child.stdout.on('data',c=>output+=c);child.stderr.on('data',c=>output+=c); + const code=await new Promise(resolve=>child.once('close',resolve)); + assert.equal(code,1,output);assert.match(output,/expected-cleanup-failure/); + const root=output.match(/FIXTURE_ROOT=(\S+)/)?.[1];assert.ok(root,output); + assert.equal(existsSync(root),false,output);assert.deepEqual(ownedProcesses(root),[]); +}); + +after(async()=>{ + const {createConnection}=await import('node:net'); + for(const port of [17440,17442,17443]) await new Promise((resolve,reject)=>{ + const socket=createConnection({host:'127.0.0.1',port}); + socket.once('connect',()=>{socket.destroy();reject(new Error(`test listener survived on ${port}`));}); + socket.once('error',e=>e.code==='ECONNREFUSED'?resolve():reject(e)); + }); +}); From ea0b676541c166d9450ec1b54935a4e222085116 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 5 Nov 2026 21:23:14 -0600 Subject: [PATCH 14/32] feat(webui): update request presentation (#797) --- packages/webui/src/public/requests.css | 15 ++++ packages/webui/src/public/requests.js | 79 ++++++++++++----- packages/webui/tests/helpers/w15-requests.mjs | 76 ++++++++++++++++ packages/webui/tests/request-browser.test.mjs | 14 +++ .../webui/tests/w15-requests-browser.test.mjs | 87 +++++++++++++++++++ 5 files changed, 251 insertions(+), 20 deletions(-) create mode 100644 packages/webui/src/public/requests.css create mode 100644 packages/webui/tests/helpers/w15-requests.mjs create mode 100644 packages/webui/tests/w15-requests-browser.test.mjs diff --git a/packages/webui/src/public/requests.css b/packages/webui/src/public/requests.css new file mode 100644 index 000000000..7ca3253f3 --- /dev/null +++ b/packages/webui/src/public/requests.css @@ -0,0 +1,15 @@ +/* Request presentation. Shell loads this after the shared styles. */ +.request-panel { border-radius: 6px; padding: 1rem; overflow-wrap: anywhere; } +.request-panel .request-head { display: flex; flex-wrap: wrap; gap: .5rem 1rem; align-items: baseline; } +.request-panel .request-head b { font-size: 1.1rem; } +.request-panel .request-phase, .request-panel .request-mode, .request-observed { font-size: .875rem; } +.request-panel .request-question, .request-panel .request-delivery, .request-panel .request-files { border: 1px solid var(--line); border-radius: 4px; padding: .75rem; margin-block: .75rem; } +.request-panel .request-question-text { font-family: inherit; font-weight: 600; } +.request-panel .request-choices { display: grid; gap: .5rem; } +.request-panel .request-choices .btn { text-align: start; } +.request-panel .btn, .request-panel summary, .request-return { min-height: 44px; } +.request-panel summary, .request-return { display: flex; align-items: center; cursor: pointer; } +.request-panel pre { max-width: 100%; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; } +.request-panel details { margin-block: .5rem; } +.request-panel .request-foot { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; } +.request-panel h3 { margin: 0 0 .5rem; font-size: 1rem; } diff --git a/packages/webui/src/public/requests.js b/packages/webui/src/public/requests.js index 10a01d9b6..f9bea6858 100644 --- a/packages/webui/src/public/requests.js +++ b/packages/webui/src/public/requests.js @@ -46,7 +46,7 @@ }; const prdDecision = s => s.phase === 'prd-review' && s.wait?.kind === 'decision' && s.wait.code === 'prd-approval' && text(s.wait.subject); - const panels = new Map(); + const panels = new Map(), summaryRuns = new WeakMap(), summaries = new Set(); let signedOut = false, timer = null, config = null, loading = null; const out = () => signedOut || document.body.classList.contains('signed-out'); const opId = prefix => prefix + '-' + [...crypto.getRandomValues(new Uint8Array(12))].map(b => b.toString(16).padStart(2, '0')).join(''); @@ -73,6 +73,8 @@ function purge() { forget(); dropAll(); config = null; loading = null; for (const el of document.querySelectorAll('.request-mount')) el.replaceChildren(); + for (const el of summaries) el.replaceChildren(); + summaries.clear(); } const signOut = () => { if (!out()) document.dispatchEvent(new CustomEvent('mosaic-signed-out', { detail: { signIn: true } })); }; document.addEventListener('mosaic-signed-out', () => { signedOut = true; purge(); }); @@ -97,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, reading: null, stored, sig: '', + 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: '', ui: { pending: null, receipt: '', confirm: null, cancelAsk: false, revokeAsk: false, reason: '', draft: stored?.text ?? '' } }; panels.set(id, p); } @@ -120,15 +122,22 @@ if (p.ui.draft === rec.text) p.ui.draft = ''; if (!p.ui.receipt) p.ui.receipt = q && q.id === rec.question && q.answer !== null ? 'The question was answered. Your earlier answer is not sent again.' : 'That question is closed. Your unsent answer was discarded.'; } - function adopt(p, s) { + function adopt(p, s, observedAt = null) { + if (p.state && s.revision < p.state.revision) return false; if (p.state && (s.revision !== p.state.revision || !same(s.candidate, p.state.candidate))) p.ui.confirm = null; if (p.state && s.snapshot !== p.state.snapshot) p.ui.revokeAsk = false; if (p.state && (s.snapshot !== p.state.snapshot || !same(s.candidate, p.state.candidate))) { p.material = null; p.materialError = ''; } + if (!observedAt && p.state && !same(s, p.state)) p.observedAt = null; if (!p.state || s.revision >= p.state.revision) p.state = s; p.missing = false; p.failed = false; p.stale = false; reconcile(p); + if (observedAt && p.state === s) { + p.observedAt = observedAt; + document.dispatchEvent(new CustomEvent('mosaic-request-state', { detail: { state: structuredClone(s), observedAt } })); + } + return true; } const WAITS = { @@ -153,9 +162,9 @@ if (!m.candidate) return '

        No current PRD candidate or approved snapshot. Waiting for a PRD submission.

        '; const c = m.candidate; let h = `

        Product PRD ${txt(c.prd_version)}

        -

        Records commit: ${txt(c.records_commit)}
        PRD: ${txt(c.prd_path)}
        PRD blob: ${txt(c.prd_blob)}
        Confirmed intent commit: ${txt(c.spec_commit)}
        Mode revision: ${txt(c.mode_revision)}

        +
        PRD identity and provenance

        Records commit: ${txt(c.records_commit)}
        PRD: ${txt(c.prd_path)}
        PRD blob: ${txt(c.prd_blob)}
        Confirmed intent commit: ${txt(c.spec_commit)}
        Mode revision: ${txt(c.mode_revision)}

        ${txt(m.prd)}
        ${m.requirements.map((r, i) => - `

        ${txt(r.id)} — ${txt(r.title)}

        ${txt(c.requirements[i].path)}
        Blob: ${txt(c.requirements[i].blob)}

        ${txt(r.text)}
        `).join('')}
        `; + `

        ${txt(r.id)} — ${txt(r.title)}

        Requirement identity

        ${txt(c.requirements[i].path)}
        Blob: ${txt(c.requirements[i].blob)}

        ${txt(r.text)}
        `).join('')}
        `; if (m.snapshot.id) { h += `

        Approved snapshot: ${txt(m.snapshot.id)}

        `; if (can) h += `
        ${p.ui.revokeAsk @@ -173,26 +182,35 @@ if (!s) return p.failed ? `

        This request could not be read.

        ` : ''; const busy = ui.pending || p.reading ? ' disabled' : '', can = p.writes && !p.stale; const q = s.question && text(s.question.id) && text(s.question.text) ? s.question : null; - let body = `

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

        `; + 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)}` : ''}

        `; if (s.project) body += `

        Project: ${txt(s.project)}${s.phase === 'cancelled' ? ' (kept)' : ''}

        `; + if (p.observedAt) body += `

        Observed:

        `; body += waitHtml(s); + if (Array.isArray(s.tasks) && s.tasks.length) body += `

        Tasks: ${s.tasks.filter(text).map(t => `${txt(t)}`).join(', ')}

        `; if (s.phase === 'complete' && !s.delivery) body += '

        Completed before delivery evidence.

        '; const d = s.delivery_evidence; - if (s.phase === 'complete' && d && d.id === s.delivery && d.request === s.request && d.artifact && d.verification) { + if (s.phase === 'complete' && d && d.id === s.delivery && d.request === s.request && d.project === s.project && d.snapshot === s.snapshot && d.artifact && d.verification) { + const pr = d.pull_request; + let prUrl = null; + try { + const u = new URL(pr?.url); + if (pr.request === s.request && pr.project === s.project && u.protocol === 'https:' && !u.username && !u.password) prUrl = u.href; + } catch { /* No valid bound PR URL. */ } body += `

        Reviewed delivery — not landed or deployed.

        Download reviewed HTML

        -

        Approved snapshot: ${txt(d.snapshot)}
        Task: ${txt(d.task_ref)}
        +

        ${prUrl ? `Open pull request` : 'PR link unavailable.'}

        Delivery evidence

        Approved snapshot: ${txt(d.snapshot)}
        Task: ${txt(d.task_ref)}
        Code commit: ${txt(d.commit)}
        Review request: ${txt(d.review_request)}
        Verdict: ${txt(d.verdict)}
        Verification record: ${txt(d.verification.record_path)} at ${txt(d.verification.record_commit)}
        - Blob: ${txt(d.artifact.blob)}
        SHA-256: ${txt(d.artifact.sha256)}

        `; + Blob: ${txt(d.artifact.blob)}
        SHA-256: ${txt(d.artifact.sha256)}

        `; } body += prdHtml(p, busy, can); if (q) { body += q.answer === null ? `

        The project manager asks:

        ${txt(q.text)}

        ` - : `

        Answered: ${txt(q.text)}

        `; + : `

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

        `; } - if (!can) body += '

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

        '; + 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 && ui.confirm) { const c = ui.confirm; body += `

        ${txt(c.question)}

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

        @@ -202,7 +220,7 @@
        `; } else if (can && (s.phase === 'selection' || prdDecision(s) && p.material?.candidate) && p.inbox) { body += `

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

        ${p.inbox.console.options.map(o => - ``).join(' ')}
        `; + ``).join(' ')}
      `; } if (can && pickerOpen(s)) { const picked = s.wait.code === 'request-attachment-selected' ? s.wait.subject : null; @@ -211,6 +229,7 @@ ``).join(' ')}` : '

      No project is available to you for this request.

      '}` : '

      The projects you may use could not be read. Use Read again.

      '; } + if (p.stale && openQuestion(s) && ui.draft) body += `

      Unsent answer retained:

      ${txt(ui.draft)}
      `; if (can && openQuestion(s)) { const lost = p.stored && p.stored.question === s.question.id; body += `
      @@ -231,8 +250,9 @@ ` : ``}`; } + body += `

      Files

      Files unavailable.

      `; body += `

      ${txt(ui.receipt)}

      `; - body += `

      ${p.stale ? 'Could not refresh; showing the last state. ' : ''}

      `; + body += `

      ${p.stale ? 'Could not refresh; showing the last state. ' : ''}${p.reading ? 'Refreshing request…' : ``}

      `; return `
      ${body}
      `; } @@ -246,6 +266,8 @@ const area = inside && a.matches('textarea') ? { id: a.id, start: a.selectionStart, end: a.selectionEnd } : null; const mark = inside && !area ? [...a.attributes].find(x => x.name.startsWith('data-request-')) : null; el.innerHTML = html(p); + const files = el.querySelector('[data-request-files]'); + if (files && typeof window.MosaicFiles?.mount === 'function') window.MosaicFiles.mount({ container: files, requestIds: [p.id] }); if (!inside) continue; let focus = null; if (area) { @@ -294,9 +316,9 @@ try { const { res, json } = await call(`/api/requests/${enc(p.id)}`); if (out() || panels.get(p.id) !== p) return; - if (res?.ok && isState(json, p.id)) { adopt(p, json); await side(p); } - else if (res?.status === 404) { p.state = null; p.missing = true; p.failed = false; } - else if (p.state) { p.stale = true; p.material = null; p.inbox = null; p.ui.confirm = null; p.ui.revokeAsk = false; } + if (res?.ok && isState(json, p.id) && adopt(p, json, new Date().toISOString())) { await side(p); } + else if (res?.status === 404 && !p.state) { p.state = null; p.missing = true; p.failed = false; } + else if (p.state) { p.stale = true; p.inbox = null; p.ui.confirm = null; p.ui.revokeAsk = false; } else p.failed = true; p.at = Date.now(); if (out() || panels.get(p.id) !== p) return; @@ -426,7 +448,7 @@ // One use, whatever came back. ui.pending = null; ui.confirm = null; ui.receipt = res?.ok && json?.id === c.id && json.op === 'resolved' && json.choice === c.choice - ? `Recorded: you chose “${c.text}”. Reading the request.` : notAnswered(refusal(res, json, 'outcome-unknown')); + ? `Recorded: you chose “${c.text}” for decision ${c.id}${c.candidate ? `, product PRD ${c.candidate.prd_version}` : ''}. Execution owner unavailable. Reading the request.` : notAnswered(refusal(res, json, 'outcome-unknown')); draw(p); void read(p); } @@ -436,7 +458,7 @@ const el = button?.closest('.request-panel'); if (!button || !el || out()) return; const p = panels.get(el.dataset.request); - if (!p || button.disabled || p.ui.pending || p.reading) return; + if (!p || button.disabled || p.ui.pending || p.reading || (p.stale && !('requestReload' in button.dataset))) return; const d = button.dataset; if ('requestReload' in d) { p.ui.receipt = ''; void read(p); draw(p); } else if ('requestChoice' in d) void choose(p, d.requestChoice); @@ -459,7 +481,7 @@ if (!form) return; event.preventDefault(); const p = panels.get(form.closest('.request-panel')?.dataset.request); - if (p && !out()) { if (form.matches('.request-revoke-form')) void revoke(p); else void answer(p); } + if (p && !out() && !p.stale && !p.reading) { if (form.matches('.request-revoke-form')) void revoke(p); else void answer(p); } }); document.addEventListener('input', event => { const area = event.target.closest?.('.request-answer textarea'); @@ -506,5 +528,22 @@ else void settings().then(run); } - window.MosaicRequests = Object.freeze({ mount, forget }); + // The project page supplies known IDs; each is reread through the existing authorized boundary. + async function projectSummary({ container, requestIds, project } = {}) { + if (!container || out()) return; + container.replaceChildren(); + const run = {}; summaryRuns.set(container, run); summaries.add(container); + if (!text(project) || !ID.test(project) || !Array.isArray(requestIds)) return; + const ids = [...new Set(requestIds.filter(id => text(id) && ID.test(id)))]; + const cfg = config || await settings(); + if (out() || !cfg?.on) return; + const rows = await Promise.all(ids.map(async id => { + const { res, json } = await call(`/api/requests/${enc(id)}`); + return res?.ok && isState(json, id) && json.project === project ? json : null; + })); + if (out() || !container.isConnected || summaryRuns.get(container) !== run) return; + const valid = rows.filter(Boolean); + container.innerHTML = valid.length ? `

      Requests

      ${valid.map(s => `

      Request ${txt(s.request)} · Phase: ${txt(s.phase)}${s.snapshot ? ' · PRD approved' : ''}

      `).join('')}
      ` : '

      Request information unavailable.

      '; + } + window.MosaicRequests = Object.freeze({ mount, forget, projectSummary }); })(); diff --git a/packages/webui/tests/helpers/w15-requests.mjs b/packages/webui/tests/helpers/w15-requests.mjs new file mode 100644 index 000000000..19b8b84bb --- /dev/null +++ b/packages/webui/tests/helpers/w15-requests.mjs @@ -0,0 +1,76 @@ +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { fixture } from '../fixture.mjs'; +import { browser } from '../browser.mjs'; + +export const THREAD = '20000000-0000-4000-8000-000000000002'; +export const MESSAGE = '40000000-0000-4000-8000-000000000004'; +export const REQUEST = '50000000-0000-4000-8000-000000000005'; +export const DECISION = '60000000-0000-4000-8000-000000000006'; +export const state = extra => ({ version: 1, request: REQUEST, revision: 7, phase: 'discovery', project: 'factory', mode: 'guided', mode_revision: 1, snapshot: null, candidate: null, tasks: [], delivery: null, question: null, wait: null, ...extra }); +export const candidate = { request: REQUEST, project: 'factory', prd_version: '1.2', records_commit: 'a'.repeat(40), prd_path: 'docs/prd.md', prd_blob: 'b'.repeat(40), spec_commit: 'c'.repeat(40), mode_revision: 1, requirements: [{ id: 'REQ-1', title: 'Timer', path: 'req.json', blob: 'd'.repeat(40) }] }; +export const draft = { candidate, prd: 'A timer that counts down.', requirements: [{ id: 'REQ-1', title: 'Timer', text: 'Count down.' }], snapshot: { id: null, revision: 2 } }; +export const evidence = { id: 'delivery-1', request: REQUEST, project: 'factory', snapshot: 'snapshot-1', task_ref: 'vikunja:2/41', commit: 'a'.repeat(40), review_request: 'review-1', verdict: 'verdict-1', artifact: { blob: 'b'.repeat(40), sha256: 'c'.repeat(64) }, verification: { record_path: 'docs/verification.md', record_commit: 'd'.repeat(40) } }; +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) { + 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 boot = ${JSON.stringify(boot)}; 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)); + 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 !== '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/bus/inbox') return json({rows:boot.inbox || [], unread:0, at:'2026-11-05T12:00:00Z'}); + 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.startsWith('/api/chat/threads') && path.endsWith('/sessions')) return json({rows:[{id:'${THREAD}',name:'default',default:true}]}); + if (path.startsWith('/api/chat/threads') && !path.includes('/messages')) return json({rows:[{id:'${THREAD}',project:'${THREAD}',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}); + 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); } + window.__unexpected.push(path); return json({error:'unexpected fixture API'}, 403); + }; + })();`; + await b.call('Page.addScriptToEvaluateOnNewDocument', { source }); + await b.call('Fetch.enable', { patterns: [{urlPattern:'*'}] }); + const network = [], consoleErrors = []; + b.on('Runtime.exceptionThrown', e => consoleErrors.push(e.exceptionDetails?.text || 'Runtime exception')); + 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 (url.startsWith(f.base + '/') || url === 'about:blank' || url.startsWith('data:')) void b.call('Fetch.continueRequest', {requestId:e.requestId}).catch(() => {}); + else { network.push(url); void b.call('Fetch.failRequest', {requestId:e.requestId,errorReason:'BlockedByClient'}).catch(() => {}); } + }); + const route = `#/chat/threads/${THREAD}`; + const navigate = async width => { + await b.viewport(width, 1000); await b.evaluate('window.__beforeNavigate=true;true'); await b.navigate(f.base + '/?width=' + width + route); + await b.evaluate(`(async()=>{const end=Date.now()+10000;while(window.__beforeNavigate || !document.querySelector('.request-panel')){if(Date.now()>end)throw Error('request panel missing');await new Promise(r=>setTimeout(r,30))}return true})()`); + }; + const shot = async (name, width) => { + if (!process.env.MOSAIC_UX_SHOTS) return []; + await mkdir(process.env.MOSAIC_UX_SHOTS, {recursive:true}); + const viewport = join(process.env.MOSAIC_UX_SHOTS, `requests-${name}-${width}.png`); + const full = join(process.env.MOSAIC_UX_SHOTS, `requests-${name}-${width}-full.png`); + await b.evaluate("document.querySelector('.request-panel').scrollIntoView({block:'start'});window.scrollBy(0,-90);true"); + await b.screenshot(viewport); + const {cssContentSize} = await b.call('Page.getLayoutMetrics'); + const {data} = await b.call('Page.captureScreenshot', {format:'png',captureBeyondViewport:true,clip:{x:0,y:0,width:Math.ceil(cssContentSize.width),height:Math.ceil(cssContentSize.height),scale:1}}); + await writeFile(full, Buffer.from(data,'base64')); + const continuation = join(process.env.MOSAIC_UX_SHOTS, `requests-${name}-${width}-continuation.png`); + await b.evaluate("(()=>{let e=document.querySelector('.request-panel').parentElement;while(e && !(e.scrollHeight > e.clientHeight && /(auto|scroll)/.test(getComputedStyle(e).overflowY))) e=e.parentElement;if(e)e.scrollTop=e.scrollHeight;return true})()"); + await b.screenshot(continuation); + return [viewport,full,continuation]; + }; + return {f,b,route,navigate,shot,network,consoleErrors}; +} diff --git a/packages/webui/tests/request-browser.test.mjs b/packages/webui/tests/request-browser.test.mjs index 556e55901..9a445f772 100644 --- a/packages/webui/tests/request-browser.test.mjs +++ b/packages/webui/tests/request-browser.test.mjs @@ -578,3 +578,17 @@ test('reviewed delivery shows a download and bound evidence separately from depl for (const value of [evidence.snapshot, evidence.task_ref, evidence.commit, evidence.review_request, evidence.verdict, evidence.verification.record_commit, evidence.artifact.sha256]) assert.ok(panel.includes(value)); assert.equal(await b.evaluate(`document.querySelector('${P} .request-delivery a').getAttribute('href')`), `/api/requests/${R}/artifact/delivery-1`); }); + +test('reviewed delivery refuses evidence bound to another project or snapshot', async t => { + const evidence = { id: 'delivery-1', request: R, project: 'other', snapshot: 'prd-approved', task_ref: 'vikunja:2/41', + artifact: { blob: 'b'.repeat(40), sha256: 'c'.repeat(64) }, verification: { record_path: 'docs/verification.md', record_commit: 'd'.repeat(40) } }; + const complete = state({ ...APPROVED, phase: 'complete', delivery: evidence.id, delivery_evidence: evidence }); + const e = await setup(t, { boot: { states: { [R]: complete }, drafts: { [R]: ACTIVE_DRAFT } } }); + await e.w(`document.querySelector('${P} .request-snapshot')`); + assert.equal(await e.count(`${P} .request-delivery`), 0); + await e.b.evaluate(`window.__states['${R}']=${JSON.stringify({ ...complete, revision: 15, delivery_evidence: { ...evidence, project: 'factory', snapshot: 'other' } })};true`); + await e.click(`${P} [data-request-reload]`); + await e.w(`window.__rreads.filter(p => p === '/api/requests/${R}').length >= 2 && document.querySelector('${P} [data-request-reload]')`); + assert.equal(await e.count(`${P} .request-delivery`), 0); + assert.deepEqual(await e.writes(), []); +}); diff --git a/packages/webui/tests/w15-requests-browser.test.mjs b/packages/webui/tests/w15-requests-browser.test.mjs new file mode 100644 index 000000000..a49d1d789 --- /dev/null +++ b/packages/webui/tests/w15-requests-browser.test.mjs @@ -0,0 +1,87 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { setup, state, candidate, draft, evidence, wait, question, REQUEST, DECISION } from './helpers/w15-requests.mjs'; + +const choices = [{key:'guided',text:'New project guided'}, {key:'autonomous',text:'New project autonomous'}, {key:'existing',text:'Add to existing project'}]; +const activeDraft = {...draft, snapshot:{id:'snapshot-1',revision:2}}; +const inbox = [{id:DECISION,question:'Choose a mode',console:{kind:'decision',options:choices}}]; +const scenarios = [ + ['mode-choice', state({project:null,phase:'selection',selection:{decision:DECISION},wait:wait('decision','request-mode',DECISION)}), {inbox}], + ['project-picker-empty', state({project:null,phase:'selection',route:'existing',wait:wait('setup','request-existing-project',REQUEST)}), {}], + ['pm-question', state({question,wait:wait('question','question','q-1')}), {}], + ['prd-confirmation', state({phase:'prd-review',candidate,wait:wait('decision','prd-approval',DECISION)}), {draft,allowConfirmation:true,inbox:[{id:DECISION,question:'Approve exact PRD?',console:{kind:'decision',options:[{key:'yes',text:'Approve exact PRD'},{key:'no',text:'Request changes'}]}}]}], + ['request-progress', state({phase:'implementation',snapshot:'snapshot-1',candidate,question:{...question,answer:'answer-1'},tasks:['vikunja:2/41']}), {draft:activeDraft}], + ['request-refresh-failed', state({question,wait:wait('question','question','q-1')}), {}], + ['reviewed-result', state({phase:'complete',snapshot:'snapshot-1',candidate,delivery:evidence.id,delivery_evidence:evidence}), {draft:activeDraft}], + ['result-evidence', state({phase:'complete',snapshot:'snapshot-1',candidate,delivery:evidence.id,delivery_evidence:evidence}), {draft:activeDraft}], + ['legacy-complete', state({phase:'complete'}), {}], + ['pr-link-present-and-absent', state({phase:'complete',snapshot:'snapshot-1',candidate,delivery:evidence.id,delivery_evidence:{...evidence,pull_request:{request:REQUEST,project:'factory',url:'https://example.org/repo/pull/1'}}}), {draft:activeDraft}], +]; + +test('W15 requests fixture scenarios and screenshots', async t => { + const manifest = []; + for (const [name, current, extra] of scenarios) { + await t.test(name, async t => { + const {b, route, navigate, shot, network, consoleErrors} = await setup(t, {states:{[REQUEST]:current}, ...extra}); + for (const width of [1440,390]) { + await navigate(width); + const waitFor = async expression => b.evaluate(`(async()=>{const end=Date.now()+10000;while(!(${expression})){if(Date.now()>end)throw Error('state missing');await new Promise(r=>setTimeout(r,30))}return true})()`); + if (name === 'mode-choice') { await waitFor("document.querySelector('[data-request-choice]')"); assert.match(await b.evaluate("document.querySelector('[data-request-choice=guided]').textContent"), /^Guided/); } + if (name === 'project-picker-empty') { await waitFor("document.querySelector('.request-picker')"); assert.match(await b.evaluate("document.querySelector('.request-picker').textContent"), /No project/); } + if (name === 'prd-confirmation') { await waitFor("document.querySelector('[data-request-choice=yes]')"); await b.evaluate("document.querySelector('[data-request-choice=yes]').click();true"); await waitFor("document.querySelector('[data-request-confirm]')"); } + if (name === 'request-progress') assert.match(await b.evaluate("document.querySelector('.request-panel').textContent"), /Answer received.*not established/s); + if (name === 'legacy-complete') assert.match(await b.evaluate("document.querySelector('.request-panel').textContent"), /Completed before delivery evidence/); + if (name === 'reviewed-result') assert.match(await b.evaluate("document.querySelector('.request-delivery').textContent"), /not landed or deployed.*PR link unavailable/s); + if (name === 'result-evidence') await b.evaluate("document.querySelector('.request-delivery details').open=true;true"); + if (name === 'pr-link-present-and-absent') assert.equal(await b.evaluate("document.querySelector('.request-delivery a[target=_blank]').href"), 'https://example.org/repo/pull/1'); + if (name === 'request-refresh-failed') { + const observed = await b.evaluate("document.querySelector('.request-observed time').dateTime"); + await b.evaluate("(()=>{window.__failure=true;const a=document.querySelector('.request-answer textarea');a.value='Draft kept';a.dispatchEvent(new Event('input',{bubbles:true}));document.querySelector('[data-request-reload]').click();return true})()"); + await waitFor("document.querySelector('.request-retained-draft')"); + assert.match(await b.evaluate("document.querySelector('.request-panel').textContent"), /Could not refresh.*last state/s); + assert.equal(await b.evaluate("document.querySelector('.request-retained-draft').textContent"), 'Draft kept'); + assert.equal(await b.evaluate("document.querySelector('.request-observed time').dateTime"), observed); + assert.equal(await b.evaluate('window.__events.length'), 1); + assert.equal(await b.evaluate("document.querySelectorAll('.request-panel form').length"), 0); + } + assert.equal(await b.evaluate("document.documentElement.scrollWidth <= document.documentElement.clientWidth"), true, 'no document overflow'); + assert.equal(await b.evaluate("document.querySelector('.request-return').getAttribute('href')"), route); + assert.deepEqual(await b.evaluate('window.__fileCalls.at(-1)'), [REQUEST]); + const files = await shot(name, width); + manifest.push({scenario:name, route, width, height:1000, observation:'Fixture read at 2026-11-05T12:00:00.000Z', files, networkErrors:[...network], consoleErrors:[...consoleErrors], refusedFixtureRequests:await b.evaluate('window.__unexpected')}); + } + }); + } + if (process.env.MOSAIC_UX_SHOTS) await writeFile(join(process.env.MOSAIC_UX_SHOTS,'manifest.json'), JSON.stringify({candidate:'set after commit', command:'node --test --test-concurrency=1 packages/webui/tests/w15-requests-browser.test.mjs', result:'see w15-tests.txt', scenarios:manifest}, null, 2)); +}); + +test('project summary rereads known IDs, refuses foreign project, and sign-out clears it', async t => { + const {b,navigate} = await setup(t,{states:{[REQUEST]:state({})}}); await navigate(1440); + assert.deepEqual(await b.evaluate(`(async()=>{const el=document.createElement('div');document.body.append(el);window.__summary=el;await MosaicRequests.projectSummary({container:el,requestIds:['${REQUEST}'],project:'other'});return [el.textContent,window.__events.length]})()`), ['Request information unavailable.',1]); + assert.match(await b.evaluate(`(async()=>{await MosaicRequests.projectSummary({container:window.__summary,requestIds:['${REQUEST}'],project:'factory'});return window.__summary.textContent})()`), /factory|discovery/); + await b.evaluate("document.dispatchEvent(new Event('mosaic-signed-out'));true"); + assert.equal(await b.evaluate('window.__summary.textContent'), ''); +}); + +test('PR URLs require exact request and project binding and safe HTTPS', async t => { + const {b,navigate} = await setup(t,{states:{[REQUEST]:state({phase:'complete',snapshot:'snapshot-1',delivery:evidence.id,delivery_evidence:{...evidence,pull_request:{request:REQUEST,project:'factory',url:'javascript:alert(1)'}}})}}); await navigate(1440); + assert.equal(await b.evaluate("document.querySelectorAll('.request-delivery a[target=_blank]').length"),0); + assert.match(await b.evaluate("document.querySelector('.request-delivery').textContent"), /PR link unavailable/); +}); + + +test('optional Files module can be absent and keyboard refresh preserves focus', async t => { + const {b,navigate} = await setup(t,{states:{[REQUEST]:state({question,wait:wait('question','question','q-1')})}}); await navigate(390); + await b.evaluate("delete window.MosaicFiles;document.querySelector('[data-request-reload]').focus();true"); + await b.key('Enter'); + await b.evaluate(`(async()=>{const end=Date.now()+10000;while(!document.querySelector('[data-request-reload]') || document.querySelector('[data-request-reload]').disabled){if(Date.now()>end)throw Error('refresh did not finish');await new Promise(r=>setTimeout(r,30))}return true})()`); + assert.match(await b.evaluate("document.querySelector('[data-request-files]').textContent"), /unavailable/i); + assert.notEqual(await b.evaluate('document.activeElement.tagName'), 'BODY'); + await b.key('Tab'); await b.key('Tab','Tab',8); await b.key('Escape'); + assert.equal(await b.evaluate("document.querySelector('.request-panel button[type=submit]').getBoundingClientRect().height >= 44"), true); + await b.evaluate("document.documentElement.dataset.mode='dark';true"); + await b.call('Emulation.setPageScaleFactor',{pageScaleFactor:2}); + assert.equal(await b.evaluate("document.documentElement.scrollWidth <= document.documentElement.clientWidth"),true); +}); From 92fe6c930326d4e5fd446f7bbb3a5a52edcaba51 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 5 Nov 2026 21:25:47 -0600 Subject: [PATCH 15/32] feat(webui): add guarded project remote writer (#797) --- packages/business/README.md | 6 ++ packages/business/src/organization.mjs | 18 +++++- packages/business/src/project-remotes.mjs | 31 ++++++++++ .../business/tests/project-remotes.test.mjs | 60 +++++++++++++++++++ 4 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 packages/business/src/project-remotes.mjs create mode 100644 packages/business/tests/project-remotes.test.mjs diff --git a/packages/business/README.md b/packages/business/README.md index a131e2686..e84e31efb 100644 --- a/packages/business/README.md +++ b/packages/business/README.md @@ -364,6 +364,12 @@ torn evidence refuses organization writes. Bus startup, Settings reads and launches keep using the whole validated file on disk, without consulting receipts. A stale writer lock also refuses: an operator must confirm that its old writer is gone before clearing it; the host does not race another recovering process by deleting locks automatically. Settings reads never repair evidence themselves. +The narrow `setProjectRemote` host method can fill only a missing remote on an +existing software project. It checks the actor, revision, normalized HTTPS +repository URL and the exact document delta under the same lock. It refuses +libraries, credentials in URLs, replacement and clearing. It does not contact +the remote or grant publishing authority. The Console setter is currently +closed until session liveness can be verified at the commit boundary. `launch-settings.mjs` restricts human edits to limits, model table, instance model selections, routes and assistant settings, and checks every route before publication. `plugin-settings.mjs` is the only writer of `plugins`: the host calls it, and no broker verb, `mosaic` diff --git a/packages/business/src/organization.mjs b/packages/business/src/organization.mjs index de7861f8c..d83870cb1 100644 --- a/packages/business/src/organization.mjs +++ b/packages/business/src/organization.mjs @@ -11,6 +11,7 @@ import { requireId, sha256, canonicalJson } from "./util.mjs"; import { isDailyModelQuota } from "../../plugins/src/model-quota.mjs"; import { checkReplyLimits } from "../../library/src/limits.mjs"; import { ADDITION_VERB, additionProblem } from "./project-bootstrap.mjs"; +import { remoteProblem, remoteTarget } from "./project-remotes.mjs"; export class OrganizationError extends Error { constructor(code, message = code) { @@ -345,6 +346,21 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) { }); } + // The only exception to the projects fence for an existing software mapping. + // Build from the locked document and check both the candidate and serialized bytes. + function setProjectRemote(id, { revision, project, remote }, actor) { + const guard = (old, document) => { + const agents = Object.entries(old.roles ?? {}).flatMap(([name, role]) => [name, `${role?.definition}.${name}`]); + if (actor !== old.human || actor === "tester" || agents.includes(actor)) fail("human-required"); + const problem = remoteProblem(old, document, project, remote); + if (problem) fail(problem); + }; + return commit(id, old => { + remoteTarget(old, project, remote); + return { ...old, projects: { ...old.projects, [project]: { ...old.projects[project], remote } } }; + }, { revision, actor, guard, detail: { verb: "project.remote.set", project } }); + } + // Runs `fn({ revision, document })` under the writer lock, after recovery, and nothing else may // write this business meanwhile. `fn` must be synchronous: the lock is a directory held for its // duration, and an awaited call would leave a half-checked world to whoever finds the lock gone. @@ -412,5 +428,5 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) { }); } - return { logFile, read, readRaw, write, addProject, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) }; + return { logFile, read, readRaw, write, addProject, setProjectRemote, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) }; } diff --git a/packages/business/src/project-remotes.mjs b/packages/business/src/project-remotes.mjs new file mode 100644 index 000000000..224875ed2 --- /dev/null +++ b/packages/business/src/project-remotes.mjs @@ -0,0 +1,31 @@ +// Narrow, offline validation for a first repository URL. Never include the supplied +// URL in a refusal: it may contain credentials. +import { parseGiteaUrl } from './vocabulary.mjs'; +import { canonicalJson } from './util.mjs'; + +export function remoteTarget(document, project, remote) { + const reject = code => { const error = new Error(code); error.code = code; throw error; }; + if (typeof project !== 'string' || !Object.hasOwn(document.projects ?? {}, project)) reject('project-remote-unknown'); + const entry = document.projects[project]; + if (entry.kind === 'library') reject('project-remote-library'); + if (Object.hasOwn(entry, 'remote')) reject('project-remote-configured'); + if (!parseGiteaUrl(remote, { repo: true }).url) reject('project-remote-invalid'); +} + +export function remoteProblem(old, next, project, remote) { + try { remoteTarget(old, project, remote); } catch (error) { return error.code; } + const expected = { ...old, projects: { ...old.projects, [project]: { ...old.projects[project], remote } } }; + return canonicalJson(expected) === canonicalJson(next) ? null : 'project-remote-protected'; +} + +export function getProjectRemote(writer, business, project) { + const { document, revision } = writer.read(business); + if (typeof project !== 'string' || !Object.hasOwn(document.projects ?? {}, project)) { + const error = new Error('project-remote-unknown'); error.code = error.message; throw error; + } + const entry = document.projects[project]; + if (entry.kind === 'library') { + const error = new Error('project-remote-library'); error.code = error.message; throw error; + } + return { project, remote: entry.remote ?? null, revision, firstSaveAllowed: !Object.hasOwn(entry, 'remote') }; +} diff --git a/packages/business/tests/project-remotes.test.mjs b/packages/business/tests/project-remotes.test.mjs new file mode 100644 index 000000000..cb58f5486 --- /dev/null +++ b/packages/business/tests/project-remotes.test.mjs @@ -0,0 +1,60 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { organizationWriter } from '../src/organization.mjs'; +import { getProjectRemote, remoteProblem } from '../src/project-remotes.mjs'; +import { businessDoc, rolesCopy, tmp, writeJson } from './helpers.mjs'; +import { mappingEntry } from '../../library/tests/helpers.mjs'; + +function fixture(t) { + const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true })); + const doc = businessDoc(root); + doc.projects.books = mappingEntry(); + doc.roles.chief.credentials = { gitea: { ...doc.roles.coder.credentials.gitea } }; + const dir = join(root, 'config'), file = writeJson(join(dir, 'businesses/acme.json'), doc); + return { file, writer: organizationWriter({ dir, rolesDir: rolesCopy(root) }) }; +} +const url = 'https://git.example/owner/repo.git'; +test('first save changes only the remote and survives a fresh reader', t => { + const { file, writer } = fixture(t), before = writer.read('acme'); + assert.deepEqual(getProjectRemote(writer, 'acme', 'stack'), { project: 'stack', remote: null, revision: before.revision, firstSaveAllowed: true }); + const result = writer.setProjectRemote('acme', { revision: before.revision, project: 'stack', remote: url }, 'jason'); + const after = writer.read('acme'); + const expected = structuredClone(before.document); expected.projects.stack.remote = url; + assert.deepEqual(after.document, expected); + assert.equal(result.revision, after.revision); + assert.deepEqual(getProjectRemote(writer, 'acme', 'stack'), { project: 'stack', remote: url, revision: after.revision, firstSaveAllowed: false }); + const log = readFileSync(writer.logFile('acme'), 'utf8').trim().split('\n').map(JSON.parse); + assert.deepEqual(log.at(-1).changes.map(x => x.path), ['projects.stack.remote']); + assert.equal(JSON.parse(readFileSync(file, 'utf8')).projects.stack.remote, url); +}); + +test('refusals do not change the business and never echo a supplied credential', t => { + const { file, writer } = fixture(t), before = writer.read('acme'), bytes = readFileSync(file); + const refuse = (args, actor, code) => { + assert.throws(() => writer.setProjectRemote('acme', { revision: before.revision, project: 'stack', remote: url, ...args }, actor), e => e.code === code && !e.message.includes('secret')); + assert.deepEqual(readFileSync(file), bytes); + }; + for (const value of ['http://git.example/a/b', 'https://user:secret@git.example/a/b', 'https://git.example/a/b?secret=x', 'https://git.example/a/b#secret', 'https://git.example/a/../b', 'https://GIT.example/a/b', '', null]) + refuse({ remote: value }, 'jason', 'project-remote-invalid'); + refuse({ project: 'missing' }, 'jason', 'project-remote-unknown'); + refuse({ project: 'books' }, 'jason', 'project-remote-library'); + refuse({ revision: 'stale' }, 'jason', 'revision-conflict'); + refuse({}, 'tester', 'human-required'); + refuse({}, 'coder', 'human-required'); + const next = structuredClone(before.document); next.projects.stack.remote = url; + assert.throws(() => writer.write('acme', next, { revision: before.revision, actor: 'jason' }), { code: 'organization-projects-protected' }); + next.vars['tracker.pollSeconds']++; + assert.equal(remoteProblem(before.document, next, 'stack', url), 'project-remote-protected'); + writer.setProjectRemote('acme', { revision: before.revision, project: 'stack', remote: url }, 'jason'); + const saved = readFileSync(file), revision = writer.read('acme').revision; + assert.throws(() => writer.setProjectRemote('acme', { revision, project: 'stack', remote: 'https://git.example/other/repo' }, 'jason'), { code: 'project-remote-configured' }); + assert.deepEqual(readFileSync(file), saved); +}); + +test('the writer lock refuses competing remote writes', t => { + const { writer } = fixture(t), revision = writer.read('acme').revision; + writer.hold('acme', () => assert.throws(() => writer.setProjectRemote('acme', { revision, project: 'stack', remote: url }, 'jason'), { code: 'organization-busy' })); + assert.equal(writer.read('acme').revision, revision); +}); From 6c123ce671d559482f2215c6935d083e5d742d7d Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 5 Nov 2026 21:44:24 -0600 Subject: [PATCH 16/32] fix(harness): report Claude helper boundary failures (#797) --- BUILD-LOG.md | 9 ++++ adapters/README.md | 7 ++- adapters/claude/adapter.sh | 41 ++++++++++------ adapters/claude/stream.mjs | 9 ++-- docs/SESSIONS.md | 1 + packages/harness/tests/bundle.test.mjs | 2 +- .../harness/tests/claude-session.test.mjs | 22 ++++++--- .../harness/tests/model-activity.test.mjs | 49 ++++++++++++++++++- 8 files changed, 113 insertions(+), 27 deletions(-) diff --git a/BUILD-LOG.md b/BUILD-LOG.md index 7a1eee0ef..27346833f 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -6211,3 +6211,12 @@ Before: candidate 1bd0077391f1925db5e66796b98540e48f13bfc2 discarded complete er After: the private stream reader renders every complete final result string independently of success/failure, retains CLI nonzero exit codes, fails error-typed zero-exit turns, and withholds result text on stream-integrity/helper marker failures. The adapter's existing success-only session persistence remains unchanged. The Claude plugin declares adapter-owned activity in its existing bundle environment; the runner reads module metadata, ignores ambient declarations and keeps stdout fallback for Pi/worker/mock paths. No core harness-name conditional, watchdog timing/default, broker, roles, contracts, policy or live state change. Bundle tests pin the helper SHA-256 to 9c2cc28b7e719c1d49913401fffc952759d8633e50ae39ca36bfc50729b9415b. Verification: before product edits, whole-file negative controls on 1bd007739 passed 68 and failed 7; the real installed Claude CLI HTTP 400 test failed on empty streaming stdout versus 38 text-mode bytes. It now passes with identical stdout bytes and exit 1, no activity marker and no new session ID, using only localhost, isolated HOME/configuration and a dummy key. Added subtype/is_error/nonzero controls, corrupt-after-result/oversized/truncated/helper marker-failure cases and real runner fallback controls. Final frozen gate: 414 passed, 0 failed/skipped/cancelled/todo across 31 whole files, one node --test invocation per file, at most two concurrently. Node v24.22.1 on the requested PATH; set -o pipefail and fresh scratch TMPDIR. The initial broad gate's sole architecture failure (412/413) is retained honestly; moved the declaration into the plugin and reran all 31 files green. Whitespace, source hashes and append-only prefix checks pass. Report: /mnt/storage/scratch/mosaic-lead/mvp3/coder-stall-fix-r2-report.md; per-file TAPs, exact commands, negative controls, source/pin scans and hashes: coder-stall-fix-r2-evidence/. Local commit follows verification; independent exact-commit review remains with the lead. No push, stash, forge write, real model call, contact with 10.1.1.53, deployment or live recovery. + + +## 2026-11-05 — MVP 3 coder-stall review F2 correction (#797), local implementation handoff + +Before: 8094901bce2d46f952786eea6469643d8dac8868 propagated helper death/load errors without the fixed stream diagnostic; Node exit 1 or SIGKILL status 137 could also be ordinary complete CLI error codes. Explicit correction charter authorizes one new local commit on fix/mvp3-coder-stall-797. Queue next coder refused this linked worktree; no workaround or forge/queue mutation. + +After: the adapter captures a private fd-3 completion handshake independently of answer stdout, CLI stderr and exit status. The helper's child does not inherit the completion descriptor. Complete CLI results retain their bytes and nonzero codes; invalid/absent completion emits exactly the fixed diagnostic and cannot save a new session. Helper stacks and shell crash text stay private. No completion file or writable mount, no broker/role/contract changes, and no watchdog timing/default changes. Helper SHA-256 and bundle pin: ce66fb2581dd789163f227030b71a26af04ce0d165fc5da6da6818eeac044ed0. + +Verification: added adjacent SIGKILL, missing-helper, static-import and runtime-exception regressions plus complete CLI exit-1/137 byte-parity controls. Before product edits, whole-file baseline repeat had 20 pass/4 intended fail; the first run's additional short-watchdog assertion failure is retained honestly. Focused fixed file passes 24/24. Final requested matrix: 420 pass, 0 fail/skip/cancel/todo across 31 whole files, one sequential node --test invocation per file. Node v24.22.1, requested PATH first, set -o pipefail and fresh TMPDIR from mktemp -d /mnt/storage/scratch/tmp/XXXXXX. Real CLI localhost HTTP 400 retains identical 38 stdout bytes, exit 1 and empty stderr, no activity/session. Adapted direct and 13 managed hostile/fallback probes pass; managed helper death/missing-helper stderr is exact, with no activity/session and recorded descendants reaped. Five real bubblewrap invocations preserve read-only stream.mjs/session.json and distinguish helper SIGKILL from handled CLI errors at 1/137. Source/pin scans, whitespace and append-only prefix checks pass. Evidence and report: /mnt/storage/scratch/mosaic-lead/mvp3/coder-stall-fix-r3-report.md and coder-stall-fix-r3-evidence/. Local review candidate only; independent exact-commit review remains with the lead. No push, stash, amend/rewrite, forge write, real model call, contact with 10.1.1.53, deployment or live operation. diff --git a/adapters/README.md b/adapters/README.md index e60a3597f..4faacb40d 100644 --- a/adapters/README.md +++ b/adapters/README.md @@ -95,7 +95,12 @@ activity; its final stdout may be a synthetic error. The runner retains stdout activity marking for Pi and mock adapters. Malformed or incomplete streams emit only the fixed diagnostic and fail the turn; a failed or interrupted first turn does not persist a reusable session ID. The helper is mounted read-only and -its digest is recorded in the launch bundle. Pi retains print mode and signals +its digest is recorded in the launch bundle. A private completion pipe separates +handled CLI exits from helper launch/runtime failures, even if their exit codes +collide. The adapter emits the fixed diagnostic if completion is absent or the +stream is invalid, suppressing helper stacks and shell crash text. CLI stderr +keeps its existing channel; the CLI does not inherit the completion pipe. No extra +writable mount or completion file is needed. Pi retains print mode and signals activity through its existing extension. Mock remains a test-only echo. ### A role's skills (row 64) diff --git a/adapters/claude/adapter.sh b/adapters/claude/adapter.sh index 963792ea9..6d15960ec 100644 --- a/adapters/claude/adapter.sh +++ b/adapters/claude/adapter.sh @@ -87,18 +87,31 @@ fi # text in the system prompt (row 64) ALLOWED=mcp__mosaic [ -z "${MOSAIC_TOOLS:-}" ] || ALLOWED="$MOSAIC_TOOLS,mcp__mosaic" -node "$STREAM" "$CLAUDE" -p "$MOSAIC_REQUEST" \ - --output-format stream-json --verbose --include-partial-messages \ - --system-prompt-file "$PROMPT_FILE" \ - --model "$MOSAIC_MODEL" \ - --effort "$MOSAIC_THINKING" \ - --restricted \ - --tools "${MOSAIC_TOOLS:-}" \ - --allowedTools "$ALLOWED" \ - --permission-mode dontAsk \ - --settings "$MOSAIC_CLAUDE_SETTINGS" \ - --strict-mcp-config \ - --mcp-config "$MOSAIC_CLAUDE_MCP_CONFIG" \ - --disable-slash-commands \ - "$SESSION_FLAG" "$SESSION_ID" || exit $? +# Completion travels on fd 3, independently of stdout and the CLI exit code. +# fd 4 preserves answer stdout; fd 5 preserves CLI stderr while helper/shell +# crashes stay private. The CLI never inherits the completion descriptor. +HELPER_STATUS=0 +{ + HELPER_COMPLETION=$( + { node "$STREAM" "$CLAUDE" -p "$MOSAIC_REQUEST" \ + --output-format stream-json --verbose --include-partial-messages \ + --system-prompt-file "$PROMPT_FILE" \ + --model "$MOSAIC_MODEL" \ + --effort "$MOSAIC_THINKING" \ + --restricted \ + --tools "${MOSAIC_TOOLS:-}" \ + --allowedTools "$ALLOWED" \ + --permission-mode dontAsk \ + --settings "$MOSAIC_CLAUDE_SETTINGS" \ + --strict-mcp-config \ + --mcp-config "$MOSAIC_CLAUDE_MCP_CONFIG" \ + --disable-slash-commands \ + "$SESSION_FLAG" "$SESSION_ID" 3>&1 1>&4 4>&-; } 2>/dev/null + ) || HELPER_STATUS=$? +} 4>&1 5>&2 +if [ "$HELPER_COMPLETION" != complete ]; then + printf 'claude adapter: invalid or incomplete model stream\n' >&2 + [ "$HELPER_STATUS" -ne 0 ] || HELPER_STATUS=2 +fi +[ "$HELPER_STATUS" -eq 0 ] || exit "$HELPER_STATUS" [ "$SESSION_FLAG" = --resume ] || printf '%s\n' "$SESSION_ID" > "$ID_FILE" diff --git a/adapters/claude/stream.mjs b/adapters/claude/stream.mjs index f19d3623d..29af9905f 100644 --- a/adapters/claude/stream.mjs +++ b/adapters/claude/stream.mjs @@ -1,18 +1,17 @@ // Consume Claude Code's SDK event stream privately. A complete final result // reaches stdout, including errors, with the same newline as text mode. import { spawn } from 'node:child_process'; +import { writeSync } from 'node:fs'; import { firstOutputMarker } from '../../packages/harness/src/first-output.mjs'; const mark = process.env.MOSAIC_MODEL_ACTIVITY_DIR ? firstOutputMarker(process.env.MOSAIC_MODEL_ACTIVITY_DIR) : () => {}; const [binary, ...args] = process.argv.slice(2); -const child = spawn(binary, args, { stdio: ['ignore', 'pipe', 'inherit'] }); +const child = spawn(binary, args, { stdio: ['ignore', 'pipe', 5] }); let pending = '', answer, resultSeen = false, resultFailed = false, failed = false, killTimer; const fail = () => { if (failed) return; failed = true; - // Never put event bodies, provider errors or credentials in diagnostics. - process.stderr.write('claude adapter: invalid or incomplete model stream\n'); child.kill('SIGTERM'); killTimer = setTimeout(() => child.kill('SIGKILL'), 5000).unref(); }; @@ -68,4 +67,8 @@ child.on('close', (code, signal) => { process.stdout.write(answer.endsWith('\n') ? answer : answer + '\n'); } process.exitCode = code || (failed || resultFailed ? 2 : 0); + // Only a fully consumed stream can acknowledge completion. The adapter + // owns the fixed diagnostic, including when this helper never loads/dies. + // spawn's explicit stdio keeps fd 3 out of the CLI and its descendants. + writeSync(3, failed ? 'invalid' : 'complete'); }); diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index c9dc78df7..9e4c0219c 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -2067,3 +2067,4 @@ are never rewritten or removed; corrections are new entries. 2026-11-05 | Codex implementation child | MVP 3 #797 D2 coder-stall fix on fix/mvp3-coder-stall-797 from 05fdfef9e | Claude private streaming activity marker + answer-only stdout; Pi model-event coverage; silent/interrupted controls preserved. Final Node 24.22.1 full 28-file gate: 376 pass, 0 fail/skip/cancel. Queue refused linked worktree; no workaround. Local commit only, no push/stash/live actions. Report: /mnt/storage/scratch/mosaic-lead/mvp3/coder-stall-fix-r1-report.md; independent review remains with lead. 2026-11-05 | Implementation child | MVP 3 #797 coder-stall review F1 on fix/mvp3-coder-stall-797 from 1bd007739 | Preserve complete failed Claude result stdout and exit status; module-declared model activity disables synthetic stdout marking while Pi/worker/mock fallback remains. Real localhost HTTP 400 regression fails baseline and passes fix; final Node 24.22.1 gate 414/414 across 31 whole files, no skips. One local commit only; no push/stash/forge/live actions. Queue refused linked worktree without workaround. Report: /mnt/storage/scratch/mosaic-lead/mvp3/coder-stall-fix-r2-report.md; independent review remains with lead. +2026-11-05 | Implementation child | MVP 3 #797 coder-stall review F2 on fix/mvp3-coder-stall-797 from 8094901bc | Private helper completion pipe supplies exact fixed diagnostics for SIGKILL/load/runtime failures while complete CLI errors preserve text bytes/status, with no synthetic activity or new session. Baseline 20 pass/4 intended fail; final Node 24.22.1 sequential 31-file matrix 420/420, no skips. Direct, managed and read-only bubblewrap controls pass; helper digest pin updated. One local commit only; queue refused linked worktree without workaround; no push/stash/forge/live actions. Report: /mnt/storage/scratch/mosaic-lead/mvp3/coder-stall-fix-r3-report.md; independent review remains with lead. diff --git a/packages/harness/tests/bundle.test.mjs b/packages/harness/tests/bundle.test.mjs index 7cdf1455c..5b0dec391 100644 --- a/packages/harness/tests/bundle.test.mjs +++ b/packages/harness/tests/bundle.test.mjs @@ -152,7 +152,7 @@ test("a claude-code bundle adds the wrapped gate hook and the MCP config", (t) = assert.ok(m.typed.every((n) => n.startsWith("mcp__mosaic__"))); assert.equal(m.sources.claudeStream.path, join(REPO, "adapters/claude/stream.mjs")); assert.equal(m.sources.claudeStream.sha256, sha(m.sources.claudeStream.path)); - assert.equal(m.sources.claudeStream.sha256, "9c2cc28b7e719c1d49913401fffc952759d8633e50ae39ca36bfc50729b9415b"); + assert.equal(m.sources.claudeStream.sha256, "ce66fb2581dd789163f227030b71a26af04ce0d165fc5da6da6818eeac044ed0"); assert.deepEqual(Object.keys(m.sources).sort(), ["claudeGate", "claudeStream", "gate", "mcpServer", "tools", "unslopCheck", "unslopHook", "unslopLists"]); // Row 66 (#1559): the unslop hooks run after a reply and before the next prompt, and never block. for (const [event, which] of [["Stop", "stop"], ["UserPromptSubmit", "prompt"]]) { diff --git a/packages/harness/tests/claude-session.test.mjs b/packages/harness/tests/claude-session.test.mjs index a8c8faac9..cdb88f4e2 100644 --- a/packages/harness/tests/claude-session.test.mjs +++ b/packages/harness/tests/claude-session.test.mjs @@ -20,6 +20,18 @@ const which = spawnSync("sh", ["-c", "command -v claude"], { encoding: "utf8" }) const CLAUDE = which.status === 0 ? which.stdout.trim() : null; const skip = CLAUDE ? false : "claude is not on PATH"; +// Reconstruct the pre-stream text invocation, including its success-only +// session persistence, without requiring the private helper handshake. +function textAdapterSource() { + const source = readFileSync(ADAPTER, 'utf8'); + const args = source.match(/node "\$STREAM" "\$CLAUDE"([\s\S]*?)"\$SESSION_FLAG" "\$SESSION_ID"/); + assert.ok(args, 'stream CLI invocation found'); + return source.slice(0, source.indexOf('# Completion travels')) + + '"$CLAUDE"' + args[1].replace('--output-format stream-json --verbose --include-partial-messages', '--output-format text') + + '"$SESSION_FLAG" "$SESSION_ID" || exit $?\n' + + "[ \"$SESSION_FLAG\" = --resume ] || printf '%s\\n' \"$SESSION_ID\" > \"$ID_FILE\"\n"; +} + async function session(t, script, tools = ["read", "bash"], done = (r) => `ANSWER ${JSON.stringify(r.map((x) => [x.isError, x.content.slice(0, 600)]))}`) { const dir = scratch(t); const workspace = join(dir, "ws"); @@ -148,9 +160,7 @@ for (const answer of ['Answer', ' Answer é 🦆\nsecond line\n', 'Answer\n\n'] test(`claude: streaming preserves the real CLI text answer byte for byte (${JSON.stringify(answer)})`, { skip }, async t => { const { dir, workspace, env } = await session(t, [], [], () => answer); const textAdapter = join(dir, 'text-adapter.sh'); - writeFileSync(textAdapter, readFileSync(ADAPTER, 'utf8') - .replace('node "$STREAM" "$CLAUDE"', '"$CLAUDE"') - .replace('--output-format stream-json --verbose --include-partial-messages', '--output-format text')); + writeFileSync(textAdapter, textAdapterSource()); const text = await turn({ ...env, MOSAIC_SESSION_DIR: join(dir, 'text-session') }, 'answer', workspace, textAdapter); const stream = await turn(env, 'answer', workspace); assert.equal(text.code, 0, text.stderr); @@ -178,9 +188,7 @@ test("claude: HTTP 400 keeps real text-mode stdout and exit status without model await new Promise(resolve => api.listen(0, '127.0.0.1', resolve)); t.after(() => api.close()); const textAdapter = join(dir, 'text-adapter.sh'); - writeFileSync(textAdapter, readFileSync(ADAPTER, 'utf8') - .replace('node "$STREAM" "$CLAUDE"', '"$CLAUDE"') - .replace('--output-format stream-json --verbose --include-partial-messages', '--output-format text')); + writeFileSync(textAdapter, textAdapterSource()); const results = []; for (const [name, adapter] of [['text', textAdapter], ['stream', ADAPTER]]) { const runDir = join(dir, name); @@ -200,6 +208,8 @@ test("claude: HTTP 400 keeps real text-mode stdout and exit status without model assert.deepEqual(text.stdoutBytes, Buffer.from('API Error: 400 review-local-api-error\n')); assert.deepEqual(stream.stdoutBytes, text.stdoutBytes); assert.equal(stream.code, text.code); + assert.equal(text.stderr, ''); + assert.equal(stream.stderr, ''); }); // --restricted is what keeps CLAUDE.md files and auto-memory out of the diff --git a/packages/harness/tests/model-activity.test.mjs b/packages/harness/tests/model-activity.test.mjs index 23a5fcb5d..7bfad564c 100644 --- a/packages/harness/tests/model-activity.test.mjs +++ b/packages/harness/tests/model-activity.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { spawn, spawnSync } from 'node:child_process'; -import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, statSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { watchModelOutput } from '../../cli/src/model-stall.mjs'; import { firstTurnMarker } from '../src/first-output.mjs'; @@ -31,7 +31,7 @@ function fixture(t, mode = 'tool') { return { runDir, workspace, env, cli }; } function start(t, f) { - const child = spawn('/bin/sh', [ADAPTER], { env: f.env, stdio: ['ignore', 'pipe', 'pipe'], detached: true }); + const child = spawn('/bin/sh', [f.adapter ?? ADAPTER], { env: f.env, stdio: ['ignore', 'pipe', 'pipe'], detached: true }); const kill = () => { try { process.kill(-child.pid, 'SIGTERM'); } catch {} }; let stdout = Buffer.alloc(0), stderr = ''; child.stdout.on('data', b => { stdout = Buffer.concat([stdout, b]); }); @@ -142,6 +142,51 @@ test('Claude helper marker failure keeps the fixed diagnostic and emits no resul assert.equal(existsSync(join(f.runDir, 'proxy-first-output')), false); }); +// These failures happen outside the helper's internal fail() handler. +for (const mode of ['sigkill', 'missing', 'static-import', 'runtime']) { + test(`Claude helper ${mode} failure reports only the fixed diagnostic without activity or a session`, { timeout: 10_000 }, async t => { + const f = fixture(t); + if (mode === 'sigkill') { + writeFileSync(f.cli, `#!/bin/sh\nexec '${process.execPath}' -e 'process.kill(process.ppid, "SIGKILL")'\n`); + } else { + f.adapter = join(f.runDir, 'adapter.sh'); + symlinkSync(ADAPTER, f.adapter); + // The adapter resolves the helper beside its invocation path. Do not + // modify repository source to inject a missing/broken module. + if (mode !== 'missing') writeFileSync(join(f.runDir, 'stream.mjs'), mode === 'static-import' + ? "import './credential-canary-missing.mjs';\n" + : "throw Error('credential-canary-runtime');\n"); + } + const result = await start(t, f).done; + assert.notEqual(result.code, 0); + assert.equal(result.stdout.length, 0); + assert.equal(result.stderr, 'claude adapter: invalid or incomplete model stream\n'); + assert.equal(existsSync(join(f.runDir, 'session/claude-session-id')), false); + assert.equal(existsSync(join(f.runDir, 'proxy-first-output')), false); + }); +} + +// A handled CLI result can return the same codes as a dead/unloaded helper. +for (const code of [1, 137]) { + test(`Claude complete CLI error exit ${code} preserves text bytes without a helper diagnostic`, async t => { + const f = fixture(t); + const script = join(f.runDir, 'cli.mjs'); + writeFileSync(script, `const answer = 'API Error: fixture é 🦆\\n\\n'; + process.stdout.write(process.argv.includes('text') ? answer + : JSON.stringify({ type: 'result', subtype: 'success', is_error: true, result: answer }) + '\\n'); + process.exitCode = ${code};`); + writeFileSync(f.cli, `#!/bin/sh\nexec '${process.execPath}' '${script}' "$@"\n`); + const result = await start(t, f).done; + const text = spawnSync(f.cli, ['--output-format', 'text'], { env: f.env }); + assert.equal(text.status, code); + assert.equal(result.code, text.status); + assert.deepEqual(result.stdout, text.stdout); + assert.equal(result.stderr, ''); + assert.equal(existsSync(join(f.runDir, 'session/claude-session-id')), false); + assert.equal(existsSync(join(f.runDir, 'proxy-first-output')), false); + }); +} + for (const mode of ['failed', 'failed-success-subtype', 'failed-zero-exit', 'nonzero-success']) { test(`Claude ${mode} renders the final result independently of failure without a session or synthetic activity`, async t => { const f = fixture(t, mode), result = await start(t, f).done; From 3fd82b084065dd43b910b31023601a64a84cecd1 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 5 Nov 2026 21:50:48 -0600 Subject: [PATCH 17/32] feat(webui): W15 shell rail, drawer and Projects nav (#797) --- packages/webui/src/public/bus.js | 40 +++++- packages/webui/src/public/display.js | 4 +- packages/webui/src/public/index.html | 5 +- packages/webui/src/public/shell.css | 30 +++- packages/webui/src/public/tokens.css | 8 +- packages/webui/src/serve.mjs | 1 + packages/webui/tests/browser.test.mjs | 2 +- packages/webui/tests/chat-browser.test.mjs | 4 +- packages/webui/tests/shell.test.mjs | 10 +- packages/webui/tests/views.test.mjs | 2 +- packages/webui/tests/w15-fixture.mjs | 24 ++-- .../webui/tests/w15-shell-browser.test.mjs | 136 +++++++++++++++++- 12 files changed, 229 insertions(+), 37 deletions(-) diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index 836829a0b..5c5bebb0f 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -1252,6 +1252,10 @@ document.body.classList.toggle('on-bus', !board); $('bus-view').hidden = board; sections(path); + // Conversations owns the project tree. The optional mount receives only the route, + // and must obtain authorized project data itself. It may be absent during integration. + window.MosaicProjectTree?.mount?.({ container: $('project-tree'), path }); + document.querySelectorAll('.rail a').forEach(a => { const target = a.getAttribute('href').slice(1); const active = target === '/projects' ? /^\/(projects|chat)(\/|$)/.test(path) : target === '/' ? path === '/' : path === target; if (active) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current'); }); const chatPath = path === '/projects' ? '/chat' : /^\/projects\/(threads|rooms)\//.test(path) ? '/chat' + path.slice('/projects'.length) : /^\/projects\/[^/]+$/.test(path) ? '/chat/projects/' + path.slice('/projects/'.length) : path; window.MosaicChat?.leave(chatPath); if (path === '/settings/display' && window.MosaicDisplay) { @@ -1259,7 +1263,9 @@ } if (chatPath === '/chat' || chatPath.startsWith('/chat/')) { // Row 177 (#1675): the tester Console writes to its own threads. + const focusedHeading = document.activeElement === $('bus-view').querySelector('h1'); await window.MosaicChat.render({ path: chatPath, container: $('bus-view'), canSend: canSend || isTester, tester: isTester, navigated }); + if ((focusedHeading || shown === null) && document.activeElement === document.body) $('bus-view').querySelector('h1')?.focus({ preventScroll: true }); if (g !== gen || out()) return; if (chatPath === '/chat') { const h = $('bus-view').querySelector('h1'); if (h) { h.textContent = 'Projects'; document.title = 'Projects · Mosaic Console'; } } shown = route; schedule(); return; @@ -1472,8 +1478,20 @@ // Normalize the renderer's legacy project links without changing the chat API. function projectChrome() { const path = routeOf().split('?')[0]; - if (!(path === '/chat' || path.startsWith('/chat/') || path === '/projects' || path.startsWith('/projects/'))) return; + if (!(path === '/chat' || path.startsWith('/chat/') || path === '/projects' || path.startsWith('/projects/'))) { $('project-context').replaceChildren(); $('project-context').dataset.signature = ''; return; } const view = $('bus-view'); + const context = $('project-context'); + const conversation = view.querySelector('.chat-conversation, .chat-room'); + const projectLink = conversation?.querySelector('p a[href^="#/chat/projects/"], p a[href^="#/projects/"]'); + const overview = /^\/(?:projects\/|chat\/projects\/)([0-9a-f-]+)$/i.exec(path); + const project = projectLink?.textContent || (overview ? view.querySelector('.chat-area h1')?.textContent : ''); + const name = conversation?.querySelector('h1')?.textContent || ''; + const href = projectLink?.getAttribute('href') || (overview ? '#/projects/' + overview[1] : ''); + const signature = JSON.stringify([project || '', name, href]); + if (context.dataset.signature !== signature) { + context.dataset.signature = signature; context.replaceChildren(); + if (project && href) { const label = document.createElement('p'); label.className = 'tree-title'; label.textContent = 'Current project'; const link = document.createElement('a'); link.href = href; link.textContent = project; link.className = 'context-project'; context.append(label, link); if (name) { const title = document.createElement('p'); title.className = 'context-conversation'; title.textContent = name; context.append(title); } } + } for (const a of view.querySelectorAll('a[href^="#/chat"]')) { const h = a.getAttribute('href'); const canonical = h === '#/chat' ? '#/projects' : h.startsWith('#/chat/projects/') ? '#/projects/' + h.slice('#/chat/projects/'.length) : h.replace(/^#\/chat\/(threads|rooms)\//, '#/projects/$1/'); @@ -1485,11 +1503,25 @@ if (h && h.textContent === 'Chat') { h.textContent = 'Projects'; document.title = 'Projects · Mosaic Console'; } } } - new MutationObserver(projectChrome).observe($('bus-view'), { childList: true, subtree: true }); + let lastViewFocus = null; + $('bus-view').addEventListener('focusin', e => { lastViewFocus = e.target; }); + new MutationObserver(() => { + projectChrome(); + if (lastViewFocus && !lastViewFocus.isConnected && document.activeElement === document.body) { + const replacement = lastViewFocus.id && document.getElementById(lastViewFocus.id); + (replacement && !replacement.disabled ? replacement : $('bus-view').querySelector('h1'))?.focus({ preventScroll: true }); + } + }).observe($('bus-view'), { childList: true, subtree: true }); // The drawer keeps the background inert and returns focus to its opener. const nav = $('console-nav'), opener = $('nav-open'), closer = $('nav-close'), scrim = $('nav-scrim'); + const processing = $('processing-controls'), mobileProcessing = $('processing-mobile'); + function placeProcessing() { + if (innerWidth < 760 && processing.parentElement !== mobileProcessing) mobileProcessing.append(processing); + else if (innerWidth >= 760 && processing.parentElement !== nav) nav.insertBefore(processing, nav.querySelector('.tree-title')); + } + placeProcessing(); let drawerOpen = false; - const background = [document.querySelector('.cmdbar'), $('main'), $('inspector'), document.querySelector('.foot')]; + const background = [document.querySelector('.cmdbar'), document.querySelector('.rail'), mobileProcessing, $('main'), $('inspector'), document.querySelector('.foot')]; function drawer(open, restore = true) { drawerOpen = open; document.body.classList.toggle('nav-open', open); opener.setAttribute('aria-expanded', String(open)); scrim.hidden = !open; @@ -1510,7 +1542,7 @@ else if (!e.shiftKey && document.activeElement === controls.at(-1)) { e.preventDefault(); controls[0].focus(); } }); nav.addEventListener('click', e => { if (drawerOpen && e.target.closest('a[href^="#/"]')) drawer(false, false); }); - window.addEventListener('resize', () => { if (drawerOpen && innerWidth >= 760) drawer(false, false); else if (drawerOpen) $('nav-overflow').hidden = nav.scrollHeight <= nav.clientHeight + 1; }); + window.addEventListener('resize', () => { if (drawerOpen && innerWidth >= 760) drawer(false, false); placeProcessing(); if (drawerOpen) $('nav-overflow').hidden = nav.scrollHeight <= nav.clientHeight + 1; }); window.addEventListener('hashchange', () => render(true)); $('refresh').addEventListener('click', () => render(false)); $('pause').addEventListener('click', () => setTimeout(schedule)); // after app.js flips aria-pressed diff --git a/packages/webui/src/public/display.js b/packages/webui/src/public/display.js index 438e1b2ad..5e6d13150 100644 --- a/packages/webui/src/public/display.js +++ b/packages/webui/src/public/display.js @@ -32,7 +32,7 @@ window.addEventListener('hashchange', e => { const destination = location.hash || '#/'; if (current === '#/settings/display' && destination !== current && dirty) { - e.stopImmediatePropagation(); pending = destination; history.replaceState(null, '', current); dialog.showModal(); dialog.querySelector('[data-choice=save]').focus(); return; + e.stopImmediatePropagation(); pending = destination; history.replaceState(null, '', current); document.getElementById('display-leave-status').textContent = ''; dialog.showModal(); dialog.querySelector('[data-choice=save]').focus(); return; } current = destination; }); @@ -45,7 +45,7 @@ if (choice === 'edit') { pending = null; document.querySelector('#display-form input')?.focus(); return; } const destination = pending; pending = null; if (choice === 'save') sessionStorageNotice(); location.hash = destination; }); - function sessionStorageNotice() { const a = document.getElementById('announce'); if (a) a.textContent = 'Sidebar display saved.'; } + function sessionStorageNotice() { const a = document.getElementById('display-announcement'); if (a) a.textContent = 'Sidebar display saved.'; } document.addEventListener('mosaic-signed-out', () => { draft = { ...saved }; dirty = false; pending = null; if (dialog.open) dialog.close(); }); window.MosaicDisplay = { render }; })(); diff --git a/packages/webui/src/public/index.html b/packages/webui/src/public/index.html index 423992a7b..698f06623 100644 --- a/packages/webui/src/public/index.html +++ b/packages/webui/src/public/index.html @@ -13,8 +13,9 @@

      +
      -