Merge integrate/w15-slices (70443895e) into integrate/mvp3-ux-1106

This commit is contained in:
jason.woltje committed 2026-11-05 22:40:07 -06:00
commit 5a346825b7
58 files changed
+2524 -92

No files matched your search

+30 -2
View File
@@ -160,7 +160,7 @@ export const USER_VERBS = Object.freeze(['project.list', 'thread.create', 'threa
'thread.share', 'thread.unshare', 'thread.shares']);
// Row 252 (#1753, design sections 2-3): the only verbs a Console owner cap reaches. Every other verb
// refuses it human-required before its own checks, and these refuse every other cap.
export const CONSOLE_VERBS = Object.freeze(['request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
export const CONSOLE_VERBS = Object.freeze(['project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
// Task 3 (#1839): and the owner's intake controls. The reads and writes are the same lifecycle methods the PM's
// unsuffixed verbs reach, with the owner's verified (provider, iss, sub) as the only caller; the PM-only
// request.question and the PM's own reads keep their unsuffixed names and refuse a console cap.
@@ -441,6 +441,7 @@ function checkBusiness(name, b) {
export class Broker {
#store;
#requests;
#workspaceRead;
#requestRecoveryPending = false;
#businesses;
#sessions = new Map();
@@ -522,7 +523,7 @@ export class Broker {
#clock = 0;
#processing;
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null }) {
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null, workspaceRead = null }) {
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
// Row 194 b15 (sage-2501): the function-argument checks come before anything reads the store.
if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function');
@@ -577,6 +578,7 @@ export class Broker {
this.#requirements = loadRequirements();
// These are host capabilities, like project/library; no filesystem module enters the client closure.
this.#requestJournal = requirementsHost?.journal;
this.#workspaceRead = workspaceRead;
this.#builtinBinding = requirementsHost?.binding;
this.#builtin = requirementsHost?.builtin;
this.#catalog = requirementsHost?.catalog;
@@ -5432,6 +5434,32 @@ export class Broker {
return { ended: ended.map((h) => ({ thread: h.thread, session: h.session, run: h.holder_run })) };
}
this.#processingGate(s, request.verb);
if (request.verb === 'request.workspace.console') {
if (!s.console || s.human !== this.#business(s.business).human) fail('human-required');
keys(args, ['request'], ['request']);
if (!this.#requests || !this.#workspaceRead) fail('request-not-configured');
return Promise.resolve().then(async () => {
const before = await this.#requests.get(s, structuredClone(args));
this.#requestAuthorize(s, before, 'draft');
const result = await this.#workspaceRead(before);
const after = await this.#requests.get(s, structuredClone(args));
this.#requestAuthorize(s, after, 'draft');
if (before.revision !== after.revision) fail('request-revision-conflict');
this.#secretCheck(result);
return result;
}).catch(e => { throw this.#refused(s, e instanceof BusError ? e : new BusError(e.code ?? 'request-failed'), request); });
}
if (request.verb === 'project.remote.get.console' || request.verb === 'project.remote.set.console') {
if (!s.console || s.human !== this.#business(s.business).human) fail('human-required');
if (!this.#organization) fail('not-configured');
const write = request.verb === 'project.remote.set.console';
keys(args, write ? ['project', 'revision', 'remote'] : ['project'], write ? ['project', 'revision', 'remote'] : ['project']);
try { return this.#organization(s.business, request.verb, args, s.human); }
catch (e) {
const allowed = ['project-remote-unknown', 'project-remote-library', 'project-remote-invalid', 'project-remote-configured', 'project-remote-protected', 'revision-conflict', 'organization-busy', 'human-required'];
fail(allowed.includes(e.code) ? e.code : 'outcome-unknown');
}
}
if (['prd.revoke.console','request.mode.console','request.draft.console'].includes(request.verb)) {
if (!s.console || s.human !== this.#business(s.business).human) fail('human-required');
if (!this.#requests?.catalog) fail('request-not-configured');
+63
View File
@@ -0,0 +1,63 @@
// Resolve only a confirmed request's assigned coder and a host-recorded checkout.
import { constants, openSync, closeSync, fstatSync, readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { isAbsolute, join } from 'node:path';
import { workspaceDir } from '../../seat/src/session.mjs';
import { workspaceFiles } from '../../seat/src/workspace-files.mjs';
const id = s => typeof s === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/.test(s) && s !== '..';
const unavailable = () => ({ available: false, entries: [], truncated: false, contentAvailable: false, observedAt: new Date().toISOString() });
const flags = constants.O_RDONLY | constants.O_NOFOLLOW;
function proofFile(path) {
// Evidence directories are host-owned, but do not let a substituted ancestor redirect a read.
const fds = [];
try {
let dir = openSync('/', flags | constants.O_DIRECTORY); fds.push(dir);
const parts = path.split('/').filter(Boolean), file = parts.pop();
if (!file || parts.some(p => p === '.' || p === '..') || file === '.' || file === '..') throw new Error('unsafe proof');
for (const part of parts) { dir = openSync(`/proc/self/fd/${dir}/${part}`, flags | constants.O_DIRECTORY); fds.push(dir); }
const fd = openSync(`/proc/self/fd/${dir}/${file}`, flags); fds.push(fd);
const st = fstatSync(fd); if (!st.isFile() || st.size > 1024 * 1024 || st.nlink !== 1) throw new Error('unsafe proof');
return readFileSync(fd, 'utf8');
} finally { for (const fd of fds.reverse()) closeSync(fd); }
}
export function requestWorkspace({ dataRoot, state, events }) {
if (typeof dataRoot !== 'string' || !isAbsolute(dataRoot) || dataRoot.split('/').includes('..')) return unavailable();
if (!state || !id(state.business) || !id(state.request) || !id(state.project) || !id(state.pm) || !state.snapshot || !Array.isArray(state.tasks) || !state.tasks.length) return unavailable();
const binding = state.selection?.bindings?.filter(b => b.digest === state.plan_digest) ?? [];
if (binding.length !== 1 || binding[0].plan.business !== state.business || binding[0].plan.request !== state.request || binding[0].plan.project !== state.project || binding[0].plan.staff?.pm !== state.pm || !id(binding[0].plan.staff?.coder)) return unavailable();
const coder = binding[0].plan.staff.coder;
if (!Array.isArray(events) || events.length > 10000) return unavailable();
const rows = events.map(e => { try { return { ...e, body: typeof e.body === 'string' ? JSON.parse(e.body) : e.body }; } catch { return null; } }).filter(Boolean);
for (const task of state.tasks) {
if (typeof task !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.:/-]{0,159}$/.test(task)) return unavailable();
const created = rows.find(e => e.kind === 'task.created' && e.actor_role === state.pm && e.subject === task && e.body?.request === state.request && e.body?.project_key === state.project && e.body?.requirement_snapshot === state.snapshot);
const assigned = rows.findLast(e => e.kind === 'task.assigned' && e.subject === task);
if (!created || assigned?.actor_role !== state.pm || assigned.body?.role !== coder) return unavailable();
}
const root = workspaceDir(dataRoot, state.business, coder), checkout = join(root, state.project);
if (/[\n\r;,]/.test(checkout)) return unavailable();
const launches = rows.filter(e => e.kind === 'session.launched' && e.actor_role === coder);
if (launches.some(e => e.body?.thread)) return unavailable();
let proven = false;
for (const e of launches.slice(-32)) {
if (!id(e.subject)) continue;
try {
const bundle = join(dataRoot, 'launches', state.business, e.subject, 'bundle');
const manifest = JSON.parse(proofFile(join(bundle, 'manifest.json')));
if (manifest.business !== state.business || manifest.instance !== coder || manifest.run !== e.subject || !Array.isArray(manifest.projects) || !manifest.projects.includes(state.project)) continue;
// The manifest projects field includes unsafe, kept entries. The host prompt records that
// outcome; do not treat a kept entry as a verified checkout.
const prompt = proofFile(join(bundle, 'prompt.md'));
if (createHash('sha256').update(prompt).digest('hex') !== manifest.files?.['prompt.md']) continue;
const lines = prompt.split('\n').filter(x => x.startsWith('- Project clones: '));
if (lines.length !== 1) continue;
const items = lines[0].slice('- Project clones: '.length).split('; ');
const item = items.find(x => x.startsWith(`${state.project} at ${checkout}, `));
if (item && (item.startsWith(`${state.project} at ${checkout}, on branch `) || item.startsWith(`${state.project} at ${checkout}, on no branch `))) proven = true;
} catch { /* missing or unsafe evidence never selects a workspace */ }
}
if (!proven) return unavailable();
try { return workspaceFiles(checkout); } catch { return unavailable(); }
}
+19
View File
@@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url';
import { Store } from './store.mjs';
import { createRequestStore } from './request-store.mjs';
import { createRequestLifecycle } from './request-lifecycle.mjs';
import { requestWorkspace } from './request-workspace.mjs';
import { readReviewedArtifact } from '../../seat/src/artifact.mjs';
import { executeBootstrap } from './request-bootstrap.mjs';
import { hostPreparation } from './request-host.mjs';
@@ -29,6 +30,7 @@ import { initializeProxy } from '../../model-proxy/src/settings.mjs';
import { ModelKeys, modelOrganization, stopModelRuns, MODEL_STOP_TIMEOUT_MS } from './model-keys.mjs';
import { instanceSettings } from '../../business/src/instance-settings.mjs';
import { organizationWriter, OrganizationError, setReplyLimits } from '../../business/src/organization.mjs';
import { getProjectRemote } from '../../business/src/project-remotes.mjs';
import { credentialVerbs } from './credential-verbs.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { loadRole } from '../../business/src/role.mjs';
@@ -585,6 +587,7 @@ export async function startBroker({
}
const requirementsHost = createRequirementsHost({ store, businesses, builtinBinding, journal: requestJournal });
broker = new Broker({
workspaceRead: state => requestWorkspace({ dataRoot, state, events: store.all("SELECT kind,actor_role,subject,body FROM events WHERE business=? AND kind IN ('task.created','task.assigned','session.launched') ORDER BY seq LIMIT 10001", state.business) }),
requirementsHost,
requestLifecycle: services => {
const readCandidate = (candidate, business) => {
@@ -664,6 +667,22 @@ export async function startBroker({
// limits and limits.set (organization.set is limits.set) carry each token limit's count; a
// plugins.set reloads the business's limit checks in the background.
organization:settings?(proxyOrganization?.wrap ?? (operation => operation))((id,verb,args,actor,context)=>{
if (verb === 'project.remote.get.console' || verb === 'project.remote.set.console') {
const current = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
const resolved = {};
for (const instance of Object.keys(current.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: current, instance });
const live = withoutPending(id, busBusiness(current, resolved));
if (!sameFixed(booted.get(id), live) || canonicalJson(live.projects) !== known.get(id)) throw new BusError('project-remote-protected');
if (!Object.hasOwn(live.projects, args.project)) throw new BusError('project-remote-unknown');
if (verb === 'project.remote.get.console') return getProjectRemote(writer, id, args.project);
writer.setProjectRemote(id, args, actor);
const next = structuredClone(live);
next.projects[args.project].remote = args.remote;
broker.reloadBusiness(id, next);
businesses[id] = next;
known.set(id, canonicalJson(next.projects));
return getProjectRemote(writer, id, args.project);
}
if(verb==='limits')return withTokens(id,settings.read(id));
if(verb==='plugins')return plugins.read(id);
// Row 256 (#1761): the actor is the reader, so endProtected is the delete check for that reader.
+1 -1
View File
@@ -89,7 +89,7 @@ function setup(t) {
// #797 / #1839 adds the owner-only artifact read to the existing decision and request controls;
// no unsuffixed verb is ever a console verb.
test('N4: owner Console verbs have an explicit immutable whitelist and exclude decision.resolve', () => {
assert.deepEqual([...CONSOLE_VERBS], ['request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
assert.deepEqual([...CONSOLE_VERBS], ['project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']);
assert.ok(CONSOLE_VERBS.every(verb => verb.endsWith('.console')));
assert.equal(CONSOLE_VERBS.includes('decision.resolve'), false);
@@ -0,0 +1,52 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
test('only the owner Console reads and writes', t => {
const root = mkdtempSync(join(tmpdir(), 'project-remote-bus-')), store = new Store(root), calls = [];
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const business = { id: 'demo', human: 'jason', vars: { 'bus.tester': true }, arbiters: { technical: 'cto', delivery: 'pm' },
roles: { pm: { definition: 'pm', authority: { withinRole: [], crossRole: [] } }, cto: { definition: 'cto', authority: { withinRole: [], crossRole: [] } } },
projects: { stack: { root } } };
const broker = new Broker({ store, businesses: { demo: business }, organization: (...args) => { calls.push(args); return { project: 'stack', revision: 'a'.repeat(64), remote: null, firstSaveAllowed: true }; } });
const owner = broker.bindConsole({ business: 'demo', human: 'jason', subject: { provider: 'oidc', iss: 'https://id.test', sub: 'owner' } });
const cli = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const reader = broker.bindReader({ business: 'demo' }), tester = broker.bindTester({ business: 'demo' });
const get = { verb: 'project.remote.get.console', args: { project: 'stack' } };
assert.equal(broker.request(owner, get).project, 'stack');
for (const cap of [cli, reader, tester]) assert.throws(() => broker.request(cap, get), /human-required|read-only|tester-refused/);
assert.throws(() => broker.request(owner, { ...get, args: { project: 'stack', business: 'other' } }), /invalid-request/);
assert.equal(broker.request(owner, { verb: 'project.remote.set.console', args: { project: 'stack', revision: 'a'.repeat(64), remote: 'https://git.example/a/b' } }).project, 'stack');
assert.equal(calls.length, 2);
assert.throws(() => broker.bindConsole({ business: 'demo', human: 'other', subject: { provider: 'oidc', iss: 'https://id.test', sub: 'owner' } }), /human-required/);
});
test('socket refuses tester, viewer, collaborator, agent and CLI human for both remote verbs', async t => {
const { serve } = await import('../src/server.mjs');
const { Client } = await import('../src/client.mjs');
const root = mkdtempSync(join(tmpdir(), 'remote-socket-')), store = new Store(root);
const access = { accessVersion: 1, revision: 1, users: [], grants: [
{ id: 'g-1', user: 'viewer', business: 'demo', projects: ['*'], instances: ['pm'], level: 'viewer' },
{ id: 'g-2', user: 'collab', business: 'demo', projects: ['*'], instances: ['pm'], level: 'collaborator' },
] };
const business = { id: 'demo', human: 'jason', vars: { 'bus.tester': true }, arbiters: { technical: 'pm', delivery: 'pm' },
roles: { pm: { definition: 'pm', authority: { withinRole: [], crossRole: [] } } }, projects: { stack: { root } } };
const broker = new Broker({ store, businesses: { demo: business }, access: () => access, organization: () => { throw Error('unexpected writer'); } });
const caps = {
human: broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
tester: broker.bindTester({ business: 'demo' }),
viewer: broker.bindUser({ business: 'demo', user: 'viewer' }),
collaborator: broker.bindUser({ business: 'demo', user: 'collab' }),
agent: broker.bindLaunch({ business: 'demo', role: 'pm', run: 'remote-test', harness: 'pi' }),
};
const path = join(root, 'bus.sock'), server = await serve({ broker, path });
t.after(async () => { await server.close(); store.close(); rmSync(root, { recursive: true, force: true }); });
for (const [who, cap] of Object.entries(caps)) for (const [verb, args] of [
['project.remote.get.console', { project: 'stack' }],
['project.remote.set.console', { project: 'stack', revision: 'a'.repeat(64), remote: 'https://git.example/a/b' }],
]) await assert.rejects(new Client({ path, cap }).call(verb, args), e => ['human-required', 'read-only', 'tester-refused', 'access-refused'].includes(e.code), `${who} ${verb}`);
});
+2 -2
View File
@@ -90,13 +90,13 @@ const ask = (x, state, extra = {}) => x.call('pm', 'request.question', { request
options: [{ key: 'web', text: 'Web' }, { key: 'cli', text: 'CLI' }], ...extra });
test('the console verb list includes the artifact read and existing owner controls', () => {
assert.deepEqual([...CONSOLE_VERBS], ['request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
assert.deepEqual([...CONSOLE_VERBS], ['project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']);
});
test('only the signed-in owner capability reaches the console verbs and it reaches nothing else', async t => {
const x = await atSelection(t), request = x.source.request;
const args = { 'request.artifact.console': { request, delivery: 'not-a-delivery' }, 'request.get.console': { request }, 'request.projects.console': { request },
const args = { 'request.workspace.console': { request }, 'request.artifact.console': { request, delivery: 'not-a-delivery' }, 'request.get.console': { request }, 'request.projects.console': { request },
'request.answer.console': { request, revision: x.state.revision, question: 'q', op: 'o1', text: 'hi' },
'request.attach.console': { request, revision: x.state.revision, project: 'old', op: 'o2' },
'request.mode.console': { request, revision: x.state.revision, mode_revision: 1, mode: 'guided', op: 'o3' },
@@ -0,0 +1,86 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import { requestWorkspace } from '../src/request-workspace.mjs';
import { requestFixture } from './request-fixture.mjs';
function fixture(t) {
const dataRoot = mkdtempSync(join(tmpdir(), 'workspace-request-')); t.after(() => rmSync(dataRoot, { recursive: true, force: true }));
const checkout = join(dataRoot, 'workspaces', 'demo', 'builder', 'timer'); mkdirSync(checkout, { recursive: true }); writeFileSync(join(checkout, 'draft.txt'), 'not read');
const state = { business: 'demo', request: 'req1', pm: 'pm', project: 'timer', snapshot: 'snap1', tasks: ['vikunja:2/41'], plan_digest: 'plan', selection: { bindings: [{ digest: 'plan', plan: { business: 'demo', request: 'req1', project: 'timer', staff: { pm: 'pm', coder: 'builder' } } }] } };
const events = [
{ kind: 'task.created', actor_role: 'pm', subject: state.tasks[0], body: { request: 'req1', project_key: 'timer', requirement_snapshot: 'snap1' } },
{ kind: 'task.assigned', actor_role: 'pm', subject: state.tasks[0], body: { role: 'builder' } },
{ kind: 'session.launched', actor_role: 'builder', subject: 'run1', body: {} },
];
const dir = join(dataRoot, 'launches', 'demo', 'run1', 'bundle'); mkdirSync(dir, { recursive: true });
const proof = (outcome = 'on branch main at commit abc when this session started') => {
const prompt = `- Project clones: timer at ${checkout}, ${outcome}.\n`;
writeFileSync(join(dir, 'prompt.md'), prompt);
writeFileSync(join(dir, 'manifest.json'), JSON.stringify({ business: 'demo', instance: 'builder', run: 'run1', projects: ['timer'], files: { 'prompt.md': createHash('sha256').update(prompt).digest('hex') } }));
};
proof(); return { dataRoot, checkout, state, events, proof, dir };
}
test('resolves only a confirmed request and host-proven coder checkout', t => {
const f = fixture(t), read = () => requestWorkspace(f);
assert.deepEqual(read().entries, [{ name: 'draft.txt' }]);
f.state.request = 'other'; assert.equal(read().available, false); f.state.request = '../req1'; assert.equal(read().available, false); f.state.request = 'req1';
f.events[1].body.role = 'other'; assert.equal(read().available, false); f.events[1].body.role = 'builder';
f.proof('not updated at this launch (not-a-clone)'); assert.equal(read().available, false);
f.proof(); writeFileSync(join(f.dir, 'prompt.md'), 'tampered'); assert.equal(read().available, false);
f.events.pop(); assert.equal(read().available, false);
});
test('console capability is required before the workspace read and other requests are refused', async t => {
const f = await requestFixture(t);
const args = { request: f.request };
const result = await f.call('console', 'request.workspace.console', args);
assert.equal(result.available, false);
for (const principal of ['human', 'pm', 'cto', 'coder', 'reader'])
await assert.rejects(async () => f.call(principal, 'request.workspace.console', args), /human-required|read-only|tester-refused/);
await assert.rejects(async () => f.call('console', 'request.workspace.console', { request: 'someone-else' }), /request-not-found/);
await assert.rejects(async () => f.call('console', 'request.workspace.console', { request: '../other' }), /invalid-request|request-not-found/);
await assert.rejects(async () => f.call('console', 'request.workspace.console', { ...args, path: '/etc' }), /invalid-request/);
assert.throws(() => f.bus.broker.bindConsole({ business: 'demo', human: 'other', subject: { provider: 'test', iss: 'test', sub: 'other' } }), /human-required/);
});
test('real broker derives a workspace only after request and coder assignment evidence', async t => {
const f = await requestFixture(t), sub = await f.submit(); f.approve(sub); await f.activate(sub);
const { createDigest, createMarker } = await import('../src/broker.mjs');
const state = f.journal.readRequest('demo', f.request), task = 'vikunja:2/41';
const prepared = { op: 'timer-task', request: f.request, requirement: 'REQ-POMO-1', 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: 'Timer', 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: task, fields, digest: createDigest(fields), updated: new Date().toISOString() });
f.bus.broker.recordRecoveredCreate({ receipt, task_ref: task, 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, body: { role: 'coder', previous: [] } }] });
await f.call('pm', 'request.advance', { request: f.request, revision: state.revision, op: 'start' });
const checkout = join(f.f.dataRoot, 'workspaces', 'demo', 'coder', 'timer'); mkdirSync(checkout, { recursive: true }); writeFileSync(join(checkout, 'draft.js'), 'no contents read');
const bundle = join(f.f.dataRoot, 'launches', 'demo', 'coder-run', 'bundle'); mkdirSync(bundle, { recursive: true });
const prompt = `- Project clones: timer at ${checkout}, on branch main at commit abc when this session started.\n`;
writeFileSync(join(bundle, 'prompt.md'), prompt);
writeFileSync(join(bundle, 'manifest.json'), JSON.stringify({ business: 'demo', instance: 'coder', run: 'coder-run', projects: ['timer'], files: { 'prompt.md': createHash('sha256').update(prompt).digest('hex') } }));
const result = await f.call('console', 'request.workspace.console', { request: f.request });
assert.equal(result.available, true); assert.deepEqual(result.entries, [{ name: 'draft.js' }]);
});
test('a different request owner is refused before any workspace access', async t => {
const { Store } = await import('../src/store.mjs'); const { Broker } = await import('../src/broker.mjs');
const root = mkdtempSync(join(tmpdir(), 'workspace-owner-')), store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const roles = { pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } } };
let reads = 0;
const state = { business: 'demo', request: 'r1', owner: 'someone-else', pm: 'pm', revision: 1 };
const lifecycle = { get: async () => state };
for (const key of ['open', 'projects', 'propose', 'question', 'answer', 'attach', 'cancel', 'modeConsole', 'draftConsole', 'resume', 'recover']) lifecycle[key] = () => {};
const broker = new Broker({ store, businesses: { demo: { id: 'demo', human: 'jason', roles, arbiters: { technical: 'pm', delivery: 'pm' }, projects: {} } }, requestLifecycle: () => lifecycle, workspaceRead: () => { reads++; return {}; } });
const cap = broker.bindConsole({ business: 'demo', human: 'jason', subject: { provider: 'test', iss: 'test', sub: 'owner' } });
await assert.rejects(async () => broker.request(cap, { verb: 'request.workspace.console', args: { request: 'r1' } }), /request-access-refused/);
assert.equal(reads, 0);
});
+6
View File
@@ -371,6 +371,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`
+17 -1
View File
@@ -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)) };
}
+31
View File
@@ -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') };
}
@@ -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:[email protected]/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);
});
+57
View File
@@ -0,0 +1,57 @@
// A bounded names-only walk. Directory descriptors anchor every lookup; no file is opened.
import { constants, openSync, closeSync, fstatSync, lstatSync, opendirSync } from 'node:fs';
import { isAbsolute } from 'node:path';
import { performance } from 'node:perf_hooks';
const flags = constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW;
const nameOK = s => s && s !== '.' && s !== '..' && !s.startsWith('.') && !/[\x00-\x1f\x7f/\\]/.test(s);
export function workspaceFiles(root, { maxEntries = 100, maxDepth = 5, maxDirectories = 1000, maxMs = 250, clock = () => performance.now() } = {}) {
if (typeof root !== 'string' || !isAbsolute(root) || root.split('/').includes('..') || root.includes('\0')) throw new Error('workspace-unsafe');
const start = clock(), fds = [];
const open = path => { const fd = openSync(path, flags); fds.push(fd); return fd; };
try {
// Anchor each ancestor too, so neither the root nor an ancestor may be a symlink.
let fd = open('/');
for (const part of root.split('/').filter(Boolean)) {
if (part === '.' || part === '..') throw new Error('workspace-unsafe');
fd = open(`/proc/self/fd/${fd}/${part}`);
}
const device = fstatSync(fd).dev, entries = [];
let directories = 0, truncated = false;
const expired = () => clock() - start >= maxMs;
function walk(dirfd, prefix, depth) {
if (expired() || directories >= maxDirectories) { truncated = true; return; }
directories++;
const dir = opendirSync(`/proc/self/fd/${dirfd}`);
try {
for (;;) {
if (expired()) { truncated = true; break; }
const item = dir.readSync();
if (!item) break;
if (!nameOK(item.name)) continue;
const p = `/proc/self/fd/${dirfd}/${item.name}`, display = prefix ? `${prefix}/${item.name}` : item.name;
let st;
try { st = lstatSync(p); } catch { truncated = true; continue; }
if (st.dev !== device || st.isSymbolicLink()) continue;
if (st.isDirectory()) {
if (depth >= maxDepth) { truncated = true; continue; }
let child;
try { child = open(p); } catch { truncated = true; continue; }
try {
const opened = fstatSync(child);
if (opened.dev !== st.dev || opened.ino !== st.ino) { truncated = true; continue; }
walk(child, display, depth + 1);
} finally { closeSync(child); fds.splice(fds.indexOf(child), 1); }
if (truncated && (expired() || directories >= maxDirectories || entries.length >= maxEntries)) break;
} else if (st.isFile() && st.nlink === 1) {
if (entries.length >= maxEntries) { truncated = true; break; }
entries.push({ name: display });
}
}
} finally { dir.closeSync(); }
}
walk(fd, '', 0);
return { available: true, entries, truncated, contentAvailable: false, observedAt: new Date().toISOString() };
} finally { for (const fd of fds.reverse()) closeSync(fd); }
}
@@ -0,0 +1,35 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, linkSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { workspaceFiles } from '../src/workspace-files.mjs';
test('lists only safe relative names and never follows links', t => {
const root = mkdtempSync(join(tmpdir(), 'files-')); t.after(() => rmSync(root, { recursive: true, force: true }));
mkdirSync(join(root, 'dir')); writeFileSync(join(root, 'dir', 'ok'), ''); writeFileSync(join(root, '.secret'), '');
writeFileSync(join(root, 'hard'), ''); linkSync(join(root, 'hard'), join(root, 'hard2'));
symlinkSync('/etc', join(root, 'outside')); symlinkSync(join(root, 'dir'), join(root, 'alias'));
assert.deepEqual(workspaceFiles(root).entries, [{ name: 'dir/ok' }]);
assert.throws(() => workspaceFiles(join(root, 'alias')));
assert.throws(() => workspaceFiles(`${root}/../other`));
});
test('each walk cap reports truncation', t => {
const root = mkdtempSync(join(tmpdir(), 'files-')); t.after(() => rmSync(root, { recursive: true, force: true }));
mkdirSync(join(root, 'a')); mkdirSync(join(root, 'a', 'b')); writeFileSync(join(root, 'a', 'b', 'x'), ''); writeFileSync(join(root, 'y'), '');
for (const options of [{ maxEntries: 0 }, { maxDepth: 0 }, { maxDirectories: 1 }, { maxMs: 0 }]) assert.equal(workspaceFiles(root, options).truncated, true);
});
test('the production limits truncate wide, deep, directory-heavy and slow walks', t => {
const root = mkdtempSync(join(tmpdir(), 'files-caps-')); t.after(() => rmSync(root, { recursive: true, force: true }));
const wide = join(root, 'wide'); mkdirSync(wide);
for (let n = 0; n < 101; n++) writeFileSync(join(wide, `f${n}`), '');
const listed = workspaceFiles(wide); assert.equal(listed.entries.length, 100); assert.equal(listed.truncated, true);
const deep = join(root, 'deep'); mkdirSync(deep); let current = deep;
for (let n = 0; n < 6; n++) { current = join(current, `d${n}`); mkdirSync(current); }
writeFileSync(join(current, 'hidden-by-depth'), ''); assert.equal(workspaceFiles(deep).truncated, true);
const many = join(root, 'many'); mkdirSync(many);
for (let n = 0; n < 1001; n++) mkdirSync(join(many, `d${n}`));
assert.equal(workspaceFiles(many, { maxMs: Infinity }).truncated, true);
let ticks = 0; assert.equal(workspaceFiles(wide, { clock: () => ticks++ * 251 }).truncated, true);
});
+23
View File
@@ -360,6 +360,11 @@ note is `agents/dewey/work/wui/SLICE1-VIEWS.md`.
under `--sso` without `--send` it says the Console is read-only for decisions.
The tester listener has no decision routes. Decisions routed to agent roles
remain with those roles, using their real role/run attribution.
- The project remote module can display the configured HTTPS repository URL for
an owner session and save a missing URL when writes are enabled. A configured
URL is not verified and does not publish, set a Git
origin, grant credentials or contact a forge. Project information integration
supplies the authorized chat project to repository-key mapping.
- #797 / Gitea #1839 Task 3, the request intake panel (`src/public/requests.js`,
`src/request-http.mjs`): under each of the signed-in owner's latest three chat
messages that opened a request, the thread shows the request's phase, mode,
@@ -1047,6 +1052,24 @@ a scheduled poll accelerated in the browser test. It proves literal
content, late unsolicited arrival, pagination, full unread count and
unchanged delivery receipts. No live bus data or service is used.
## Projects, requests and files (#797)
Projects opens the project overview, its conversations and project information.
A conversation retains its exact session in the URL. The navigation drawer shows
context from the current authorized read. Sidebar display preferences stay in
this browser. Inbox cards show the recorded decision origin and receipt details.
Request panels show known request state and reviewed delivery evidence. Their
Files area and the conversation Files area read only explicitly observed request
IDs. Draft workspace files are names only; reviewed delivery files retain their
existing open and download controls. Missing or refused reads remain unavailable.
Project information can show a linked repository's configured URL or, when the
owner has write access, save a missing HTTPS URL. Saving configures the URL only;
it does not verify or publish it. The server rechecks authorization and refuses
replacement of an existing URL. Granted users and tester transports do not gain
these owner controls.
## Chat projects and threads (row 129, #1623)
Open **Chat** in the section navigation or command palette. The business project
+1 -1
View File
@@ -81,7 +81,7 @@ try {
if (ssoOn && sendOn) decisionWrite = consoleCall({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
// #797 / #1839 Task 3: the request intake controls use the same console wrapper (the signed-in
// (provider, iss, sub) is the session's, never the client's). Reads need --sso, writes --sso and
// --send. consoleCall refuses any verb that is not a console verb.
// --send. Workspace reads and project remote operations use this owner-only, verb-fenced transport.
if (ssoOn) requestRead = consoleCall({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
if (ssoOn && sendOn) requestWrite = requestRead;
busLine = `Bus: ${options.business ?? 'the running bus host\'s business'}`;
+2
View File
@@ -19,7 +19,9 @@ export const agentCaller = (call, verbs) => async (verb, args) => {
// `send` is the --send flag: without it every write is null. `owner` is the owner's transport for
// a granted user's options; it goes to createChat, which sends it only the `plugins` read.
// Request workspace reads use the owner console transport, never this shared user transport.
export function consoleBus(call, { send: sendOn = false, owner = null } = {}) {
// Project remote operations are deliberately absent: this object is also used by granted users.
const o = {
proxyCall: agentCaller(call, PROXY_VERBS),
processing: { read: () => call('processing.status'), halt: args => call('processing.halt', args), resume: args => call('processing.resume', args) },
@@ -0,0 +1,50 @@
// Owner Console transport only. No caller-supplied business, actor or subject.
import { parseGiteaUrl } from '../../business/src/vocabulary.mjs';
export const PROJECT_REMOTE_ROUTE = /^\/api\/project-remotes\/([^/]+)$/;
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/;
const one = (req, key) => req.headersDistinct[key]?.length === 1 ? req.headersDistinct[key][0] : null;
const codes = {
'project-remote-unknown': 404, 'project-remote-library': 403, 'project-remote-invalid': 400,
'project-remote-configured': 409, 'project-remote-protected': 409, 'revision-conflict': 409,
'organization-busy': 409, 'human-required': 403,
};
function json(res, status, value) {
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
res.end(JSON.stringify(value));
}
export async function projectRemoteHTTP(req, res, { path, read, write, session, live, origin }) {
const refuse = (status, error) => json(res, status, { error });
if (!['GET', 'POST'].includes(req.method)) return refuse(405, 'method-not-allowed');
if (!session) return refuse(401, 'sign-in-required');
if (!(req.method === 'GET' ? read : write)) return refuse(403, 'read-only');
const site = one(req, 'sec-fetch-site'), from = one(req, 'origin');
if (req.method === 'POST' ? from !== origin || site !== 'same-origin'
: (req.headersDistinct.origin && from !== origin) || (req.headersDistinct['sec-fetch-site'] && !['same-origin', 'none'].includes(site)))
return refuse(403, 'cross-origin');
let project;
try { project = decodeURIComponent(PROJECT_REMOTE_ROUTE.exec(path)?.[1] ?? ''); } catch { return refuse(404, 'project-remote-unknown'); }
if (!ID.test(project)) return refuse(404, 'project-remote-unknown');
let args = { project };
if (req.method === 'POST') {
if (one(req, 'content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') return refuse(400, 'invalid-request');
const chunks = []; let size = 0;
for await (const chunk of req) { size += chunk.length; if (size > 4096) return refuse(413, 'invalid-request'); chunks.push(chunk); }
let body;
try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { return refuse(400, 'invalid-request'); }
if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).length !== 2 || !Object.hasOwn(body, 'revision') || !Object.hasOwn(body, 'remote') ||
typeof body.revision !== 'string' || !/^[a-f0-9]{64}$/.test(body.revision) || typeof body.remote !== 'string' || body.remote.length > 2048)
return refuse(400, 'invalid-request');
args = { ...args, revision: body.revision, remote: body.remote };
}
if (live() !== session) return refuse(401, 'sign-in-required');
const subject = { provider: session.provider, iss: session.iss, sub: session.sub };
let result;
try { result = await (req.method === 'GET' ? read : write)(req.method === 'GET' ? 'project.remote.get.console' : 'project.remote.set.console', args, { subject }); }
catch (error) { const code = codes[error.code] ? error.code : 'outcome-unknown'; return refuse(codes[code] ?? 503, code); }
// A write admitted at spawn may already have committed. Return its receipt rather than
// reporting a sign-out as if the operation had not happened. Reads can still be withheld.
if (req.method === 'GET' && live() !== session) return refuse(401, 'sign-in-required');
if (!result || result.project !== project || !/^[a-f0-9]{64}$/.test(result.revision ?? '') || !(result.remote === null || parseGiteaUrl(result.remote, { repo: true }).url) || typeof result.firstSaveAllowed !== 'boolean' || result.firstSaveAllowed !== (result.remote === null))
return refuse(503, 'outcome-unknown');
return json(res, 200, { project: result.project, remote: result.remote, revision: result.revision, firstSaveAllowed: result.firstSaveAllowed });
}
+104 -14
View File
@@ -247,9 +247,9 @@
// ---------------------------------------------------------------- views
let inboxCount = null, messageCount = null;
function sections(route) {
const items = [['/', 'Board', '', 'board'], ['/inbox', 'Inbox', inboxCount ? `<span class="count" aria-label="${inboxCount.open} open for you">${inboxCount.open}</span>${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/chat', 'Chat', '', 'inbox'], ['/messages', 'Messages', messageCount === null ? '' : `<span class="count" aria-label="${messageCount} unread messages">${messageCount}</span>`, '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 ? `<span class="count" aria-label="${inboxCount.open} open for you">${inboxCount.open}</span>${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/projects', 'Projects', '', 'inbox'], ['/messages', 'Messages', messageCount === null ? '' : `<span class="count" aria-label="${messageCount} unread messages">${messageCount}</span>`, '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 `<li><a href="#${h}" class="s1-section"${cur ? ' aria-current="page"' : ''}><svg class="i" aria-hidden="true"><use href="/icons.svg#i-${icon}"/></svg>${l}${extra ? ` ${extra}` : ''}</a></li>`;
}).join('');
}
@@ -260,7 +260,7 @@
return `<li class="wait-item s1-dec${d.blocking ? ' is-attn' : ''}"><div class="wait-head"><a href="#/inbox/${enc(d.id)}" class="s1-dec-open">${txt(first(d.question))}</a></div>
<p class="s1-badges">${d.blocking ? blockChip('blocking') : ''}${chip(d.class, d.action)}${canDecide && d.console ? ` ${badge(d.console.kind === 'decision' ? 'Answer here' : 'Approve or Deny here', 'ok')}` : ''}</p>
<p class="small s1-auth">${approves(d.authorization)}</p>
<p class="small muted">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)}` : ''}</p></li>`;
${window.MosaicInbox?.cardMeta?.(d) ?? `<p class="small muted">Raised by ${txt(d.raised_by_role)} ${when(d.at)}</p>`}<p class="small muted">${d.task_ref ? ` · task ${taskLink(d.task_ref)}` : ''}${d.requirement_ref ? ` · ${txt(d.requirement_ref)}` : ''}</p></li>`;
}
// 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 = `<p class="receipt" id="decide-status" tabindex="-1">${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.</p>`;
body = `<p class="receipt" id="decide-status" tabindex="-1">${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.</p>${window.MosaicInbox?.receipt?.(r, a.decision) ?? ''}`;
} else if (a.confirm) {
const c = a.confirm, label = `Confirm ${c.action}`;
body = `<div class="s1-confirm" role="group" aria-labelledby="b-confirm"><h3 id="b-confirm" class="small">${label}</h3>
<dl class="kv"><dt>Id</dt><dd><code>${txt(c.id)}</code></dd><dt>Question</dt><dd class="s1-q">${txt(c.question)}</dd><dt>Answer</dt><dd><b class="s1-key">${txt(c.choice)}</b> ${txt(c.text)}</dd>${c.kind === 'decision' ? '' : `<dt>Grant</dt><dd>${grantText(c.grant)}</dd>`}</dl>
${window.MosaicInbox?.detailMeta?.(a.decision) ?? ''}${window.MosaicInbox?.receipt?.(c, a.decision) ?? ''}<dl class="kv"><dt>Id</dt><dd><code>${txt(c.id)}</code></dd><dt>Question</dt><dd class="s1-q">${txt(c.question)}</dd><dt>Answer</dt><dd><b class="s1-key">${txt(c.choice)}</b> ${txt(c.text)}</dd>${c.kind === 'decision' ? '' : `<dt>Grant</dt><dd>${grantText(c.grant)}</dd>`}</dl>
<p class="small muted">${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.</p>
<div class="s1-cmd"><button type="button" class="btn primary" data-decide="confirm"${off}>${a.pending ? 'Answering…' : label}</button><button type="button" class="btn" data-decide="cancel"${off}>Cancel</button></div></div>`;
} 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 <code>mosaic decide</code>; Console only shows them.';
return `${head('Inbox', `${readNote()} ${answered}`)}
${await composer(get)}
<h2 id="b-for-you">For you <span class="count">${rows.length}</span></h2>${body}
<h2 id="b-for-you">Open decisions <span class="count">${rows.length}</span></h2>${body}
<p class="small muted">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.</p>`;
}
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 = `<ol class="s1-opts">${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 ? `<p class="small muted">To answer, run one of these in a terminal. Copy only puts the command on your clipboard. Console sends nothing.${why}</p>`
: !c ? `<p class="small muted">This decision is routed to ${txt(d.routeTo)}, not to you, so Console offers no command for it.</p>` : '';
return `${head(`Decision ${txt(short(d.id))}`, status)}
<section aria-labelledby="b-q" class="panel"><h2 id="b-q">Question</h2><p class="s1-q">${txt(d.question)}</p>
<section aria-labelledby="b-q" class="panel"><h2 id="b-q">Question</h2><p class="s1-q">${txt(d.question)}</p>${window.MosaicInbox?.detailMeta?.(d) ?? ''}
<dl class="kv"><dt>Class</dt><dd>${chip(d.class, d.action)}${d.blocking ? ` ${blockChip('blocking')}` : ''}</dd><dt>Approval</dt><dd class="s1-auth">${approves(d.authorization)}</dd><dt>Routed to</dt><dd>${txt(d.routeTo)}</dd><dt>Raised by</dt><dd>${txt(d.raisedBy.role)} <span class="source">${txt(d.raisedBy.run)}</span></dd><dt>Raised</dt><dd>${when(d.at)}</dd>${d.taskRef ? `<dt>Task</dt><dd>${taskLink(d.taskRef)} <span class="source">${txt(d.taskRef)}</span></dd>` : ''}${d.requirementRef ? `<dt>Requirement</dt><dd>${txt(d.requirementRef)}</dd>` : ''}<dt>DM</dt><dd>${d.dm ? `sent ${when(d.dm.at)}` : d.blocking ? 'not sent' : 'not needed (not blocking)'}</dd><dt>Id</dt><dd><code>${txt(d.id)}</code></dd></dl></section>
${answer?.id === d.id ? decideHtml() : ''}
<section aria-labelledby="b-o"><h2 id="b-o">Options</h2>${help}${options}<p class="small muted" id="copy-status"></p></section>
@@ -972,7 +973,7 @@
? `<dl class="kv"><dt>Binding</dt><dd><code>${txt(n.binding)}</code></dd></dl><p class="small muted">The binding name only. Its channels, server and user ids stay in the binding file.</p>`
: empty('No notifier binding to show.', `${n.refused ? `<code>${txt(n.refused)}</code>: ` : ''}${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)}
<section class="panel" aria-labelledby="st-model-proxy"><h2 id="st-model-proxy">Model proxy</h2><p>Manage this instance’s model accounts, priorities and client key IDs.</p><a href="/settings/model-proxy">Open model proxy settings</a></section>
<section class="panel"><h2>Sidebar display</h2><p>Choose how this browser shows the sidebar.</p><a href="#/settings/display">Sidebar display</a></section><section class="panel" aria-labelledby="st-model-proxy"><h2 id="st-model-proxy">Model proxy</h2><p>Manage this instance’s model accounts, priorities and client key IDs.</p><a href="/settings/model-proxy">Open model proxy settings</a></section>
<section aria-labelledby="st-notify"><h2 id="st-notify">Notifications</h2>${notify}</section>
<section aria-labelledby="st-about" class="panel"><h2 id="st-about">About</h2><dl class="kv"><dt>Address</dt><dd><code>${txt(s.address) || 'unknown'}</code></dd><dt>Release</dt><dd>${s.release ? `<code>${txt(s.release)}</code>` : '<span class="muted">unknown: no RELEASE file was read</span>'}</dd><dt>Business</dt><dd>${txt(s.business) || none}</dd><dt>Board</dt><dd><code>${txt(s.board) || 'unknown'}</code></dd></dl></section>`;
}
@@ -1251,11 +1252,22 @@
document.body.classList.toggle('on-bus', !board);
$('bus-view').hidden = board;
sections(path);
window.MosaicChat?.leave(path);
if (path === '/chat' || path.startsWith('/chat/')) {
// 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) {
window.MosaicDisplay.render($('bus-view'), navigated); shown = route; return;
}
if (chatPath === '/chat' || chatPath.startsWith('/chat/') || /^\/projects\/[0-9a-f-]+\/settings$/i.test(chatPath)) {
// 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 });
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;
}
if (board) {
@@ -1355,7 +1367,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 +1473,86 @@
}
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(() => {});
// This context comes from the conversations renderer after its authorized read.
let projectContext = null;
document.addEventListener('mosaic-project-context', event => {
const detail = event.detail;
if (detail && typeof detail.projectId === 'string') projectContext = detail;
});
document.addEventListener('mosaic-signed-out', () => { projectContext = null; $('project-context').replaceChildren(); });
// 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/'))) { $('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 validContext = projectContext && (overview?.[1] === projectContext.projectId || conversation?.dataset.thread === projectContext.thread || path.includes('/rooms/' + projectContext.projectId) || path === '/projects/' + projectContext.projectId + '/settings');
const project = validContext ? (projectLink?.textContent || view.querySelector('.chat-area h1')?.textContent || projectContext.projectId) : '';
const name = conversation?.querySelector('h1')?.textContent || '';
const href = validContext ? '#/projects/' + encodeURIComponent(projectContext.projectId) : '';
const uuid = value => typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
const threadHref = validContext && uuid(projectContext.thread) ? '#/projects/threads/' + encodeURIComponent(projectContext.thread) + (uuid(projectContext.session) ? '/sessions/' + encodeURIComponent(projectContext.session) : '') : '';
const signature = JSON.stringify([project || '', name, href, threadHref]);
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(threadHref ? 'a' : 'p'); title.className = 'context-conversation'; title.textContent = name; if (threadHref) title.href = threadHref; 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/');
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'; }
}
}
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(':scope > .tree-title'));
}
placeProcessing();
let drawerOpen = false;
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;
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); 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
+10
View File
@@ -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; }
+85 -19
View File
@@ -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) ? `<a href="#/chat/${type}/${id}">${txt(label)}</a>` : 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) ? `<a href="${type === 'projects' ? projectPath(id) : threadPath(id)}">${txt(label)}</a>` : txt(label);
const heading = text => `<h1 tabindex="-1">${txt(text)}</h1>`;
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 ? `<ul class="chat-thread-list">${rows.map(t => `<li>${link('threads', t.id, t.name)} <span class="muted">${txt(t.instance)}${t.shared ? ` · Shared by ${txt(t.owner)}` : ''}</span></li>`).join('')}</ul>` : current.readFailed ? '<p>Threads unavailable.</p>' : '<p>No threads yet.</p>';
}
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 => `<li data-message="${esc(m.id)}"><div class="chat-message-meta"><strong>${txt(m.from_role)}</strong> ${stamp(m.at)}</div>${messageBody(m)}${withRequest.has(m.id) ? `<div class="request-mount" data-request="${esc(m.request)}"></div>` : ''}</li>`).join('');
const receipt = s.receipt;
return `<section class="chat-conversation" data-thread="${esc(t.id)}" data-session="${esc(current.session)}"><p>${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}</p>${heading(t.name)}${libraryLine(libraryOf(current, t))}${sessionControls(t)}${sharing(t)}<p class="muted">Reading never wakes the assistant. An accepted message is not an answer.</p>${threadWrites() ? `<button class="btn" type="button" id="chat-resume"${disabledSession(s.pending || current.channelDisabled)}>Resume assistant</button>` : ''}<ol class="chat-log" tabindex="0" aria-label="Thread conversation">${messages || (current.readFailed ? '<li class="muted">Messages unavailable.</li>' : '<li class="muted">No messages yet.</li>')}</ol><div class="chat-paging">${current.readFailed ? '' : s.more ? '<p>Showing earlier history. Read forward to reach the current conversation.</p><button class="btn" type="button" id="chat-next">Read next messages</button>' : '<p class="muted">Caught up to the last read.</p>'}<button class="btn" type="button" id="chat-new"${s.follow ? ' hidden' : ''}>New messages — go to end</button></div>${threadWrites() ? `<form id="chat-send-form"><label for="chat-body">Message this thread</label><textarea id="chat-body" rows="4"${disabledSession(s.pending)}>${esc(s.draft)}</textarea><button class="btn primary" type="submit"${disabledSession(s.pending || current.channelDisabled)}>Send</button></form>` : ''}<p id="chat-receipt" role="status">${txt(receipt)}</p></section>`;
return `<section class="chat-conversation" data-thread="${esc(t.id)}" data-session="${esc(current.session)}"><p>${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}</p>${heading(t.name)}${libraryLine(libraryOf(current, t))}<details class="chat-details"><summary id="chat-details-toggle">Conversation details</summary>${sessionControls(t)}${sharing(t)}</details><p class="muted">Reading never wakes the assistant. An accepted message is not an answer.</p>${threadWrites() ? `<button class="btn" type="button" id="chat-resume"${disabledSession(s.pending || current.channelDisabled)}>Resume assistant</button>` : ''}<ol class="chat-log" tabindex="0" aria-label="Thread conversation">${messages || (current.readFailed ? '<li class="muted">Messages unavailable.</li>' : '<li class="muted">No messages yet.</li>')}</ol><div class="chat-paging">${current.readFailed ? '' : s.more ? '<p>Showing earlier history. Read forward to reach the current conversation.</p><button class="btn" type="button" id="chat-next">Read next messages</button>' : '<p class="muted">Caught up to the last read.</p>'}<button class="btn" type="button" id="chat-new"${s.follow ? ' hidden' : ''}>New messages — go to end</button></div>${threadWrites() ? `<form id="chat-send-form"><label for="chat-body">Message this thread</label><textarea id="chat-body" rows="4"${disabledSession(s.pending)}>${esc(s.draft)}</textarea><p class="chat-audience">To: ${txt(t.instance)} · Private conversation</p><button class="btn primary" type="submit"${disabledSession(s.pending || current.channelDisabled)}>Send</button></form>` : ''}<p id="chat-receipt" role="status">${txt(receipt)}</p><section class="chat-files"><h2>Files</h2><div data-chat-files>Files unavailable.</div></section><div data-chat-summary></div></section>`;
}
// 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 `<section class="chat-room-entry" aria-label="Project room"><button class="btn" type="button" id="chat-room-open"${disabled(f.pending || current.channelDisabled)}>Open room</button> <a href="#/chat/rooms/${esc(project)}">Read room</a><p id="chat-room-open-receipt" role="status">${txt(f.receipt)}</p></section>`;
return `<section class="chat-room-entry" aria-label="Project room"><button class="btn" type="button" id="chat-room-open"${disabled(f.pending || current.channelDisabled)}>Open room</button> <a href="${roomPath(esc(project))}">Read room</a><p id="chat-room-open-receipt" role="status">${txt(f.receipt)}</p></section>`;
}
function roomConversation() {
const c = current, s = state(c.key), p = c.projects.find(p => p.id === c.id);
@@ -242,7 +246,7 @@
return `<li data-message="${esc(m.id)}"><div class="chat-message-meta"><strong>${txt(speaker)}</strong>${recipients} ${stamp(m.at)}</div>${messageBody({ ...m, from_role: m.from_role === 'human' ? m.from_role : speaker })}</li>`;
}).join('');
const off = !c.roomReady || c.channelDisabled || s.pending || !roomOwner();
return `<section class="chat-room" data-project="${esc(c.id)}"><p>${link('projects', c.id, p?.name ?? 'Project')}</p>${heading((p?.name ?? 'Project') + ' room')}${roomOpenControl(c.id)}${libraryLine(libraryOf(c))}<p>Room members: ${(c.members ?? []).map(m => `${txt(m.name)} (@${txt(m.instance)})`).join(', ') || 'unavailable'}</p><ol class="chat-log chat-room-log" tabindex="0" aria-label="Room conversation">${messages || (c.readFailed ? '<li>Messages unavailable.</li>' : '<li>No messages yet.</li>')}</ol><div class="chat-paging">${c.readFailed ? '' : s.more ? '<button class="btn" type="button" id="chat-next">Read next messages</button>' : '<p class="muted">Caught up to the last read.</p>'}<button class="btn" type="button" id="chat-new"${s.follow ? ' hidden' : ''}>New messages — go to end</button></div><form id="chat-room-send-form"><label for="chat-room-body">Message the room</label><p id="chat-room-help">Without leading @tags, your message goes to every member. The broker checks the addressees.</p><textarea id="chat-room-body" rows="4" aria-describedby="chat-room-help" aria-autocomplete="list" aria-controls="chat-room-completion" aria-expanded="false"${disabled(off)}>${esc(s.draft)}</textarea><div id="chat-room-tags"></div><ul id="chat-room-completion" class="chat-room-completion" role="listbox" aria-label="Room members" hidden></ul><button class="btn primary" type="submit"${disabled(off)}>Send to room</button></form><p id="chat-room-receipt" role="status">${txt(s.receipt)}</p></section>`;
return `<section class="chat-room" data-project="${esc(c.id)}"><p>${link('projects', c.id, p?.name ?? 'Project')}</p>${heading((p?.name ?? 'Project') + ' room')}${roomOpenControl(c.id)}${libraryLine(libraryOf(c))}<p>Messages are copied to selected contacts. Their replies are private.</p><p>Room members: ${(c.members ?? []).map(m => `${txt(m.name)} (@${txt(m.instance)})`).join(', ') || 'unavailable'}</p><ol class="chat-log chat-room-log" tabindex="0" aria-label="Room conversation">${messages || (c.readFailed ? '<li>Messages unavailable.</li>' : '<li>No messages yet.</li>')}</ol><div class="chat-paging">${c.readFailed ? '' : s.more ? '<button class="btn" type="button" id="chat-next">Read next messages</button>' : '<p class="muted">Caught up to the last read.</p>'}<button class="btn" type="button" id="chat-new"${s.follow ? ' hidden' : ''}>New messages — go to end</button></div><form id="chat-room-send-form"><fieldset class="chat-room-picker"${off ? ' disabled' : ''}><legend>Choose recipients</legend>${(c.members ?? []).map(m => `<label><input type="checkbox" id="chat-member-${esc(m.instance)}" data-room-member="${esc(m.instance)}"> ${txt(m.name)} (@${txt(m.instance)})</label>`).join('')}</fieldset><label for="chat-room-body">Message the room</label><p id="chat-room-help">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.</p><textarea id="chat-room-body" rows="4" aria-describedby="chat-room-help" aria-autocomplete="list" aria-controls="chat-room-completion" aria-expanded="false"${disabled(off)}>${esc(s.draft)}</textarea><div id="chat-room-offer"></div><div id="chat-room-tags"></div><ul id="chat-room-completion" class="chat-room-completion" role="listbox" aria-label="Room members" hidden></ul><button class="btn primary" type="submit"${disabled(off)}>Send to room</button></form><p id="chat-room-receipt" role="status">${txt(s.receipt)}</p></section>`;
}
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 => `<span data-room-tag="${esc(tag)}">@${txt(tag)}</span>`).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 ? `<button class="btn" type="button" data-room-offer="${esc(mentioned)}">Add ${txt(roomName(mentioned))} to recipients</button>` : '';
preview.innerHTML = tags.length ? 'To: ' + tags.map(tag => `<span data-room-tag="${esc(tag)}">@${txt(tag)}</span>`).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 = '<div class="chat-area"><p><a href="#/chat">Chat projects</a></p>';
let html = '<div class="chat-area"><p><a href="#/projects">Projects</a></p>';
if (c.error) html += `<p class="chat-error banner err" role="alert">${txt(c.error)}</p>`;
if (c.readFailed && (c.projects.length || c.threads.length || c.kind === 'thread' && state(c.key).loaded)) html += '<p role="status">Showing previously read chat content; it may be out of date.</p>';
if (c.kind === 'room') {
@@ -389,15 +397,36 @@
} 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)) + '<h2>Shared with me</h2>' + threads(c.threads.filter(t => t.shared)) + creation('thread', c.id);
if (p && !c.refused) {
if (c.settings) html += `<p><a href="#/projects/${esc(c.id)}">Project overview</a></p><h2>Project information</h2><p>Repository: ${txt(p.repo || 'Not linked')}</p><div data-chat-remote>Repository setting unavailable.</div>`;
else html += `<p><a href="#/projects/${esc(c.id)}/settings">Project information</a></p>${knownRequests.get(c.id)?.repo === p.repo && knownRequests.get(c.id)?.ids.size ? '<p>Request status from conversations opened in this session. Other work may not be shown.</p><div data-chat-summary></div>' : '<p>Request status is not loaded. Open a conversation to see its requests.</p>'}<h2>Conversations</h2>` + roomOpenControl(c.id) + threads(c.threads.filter(t => !t.shared)) + '<h2>Shared with me</h2>' + threads(c.threads.filter(t => t.shared)) + creation('thread', c.id);
}
} else {
html += heading('Chat') + '<p>Business projects and your named assistant threads.</p><div class="chat-projects">' +
html += heading('Projects') + '<p>Projects and their conversations.</p><div class="chat-projects">' +
c.projects.map(p => `<section class="panel"><h2>${link('projects', p.id, p.name)}</h2>${p.repo ? `<p class="muted">Repository: ${txt(p.repo)}</p>` : ''}${threads(c.threads.filter(t => t.project === p.id && !t.shared))}</section>`).join('') + (c.projects.length ? '' : c.readFailed ? '<p>Projects unavailable.</p>' : '<p>No projects yet.</p>') + '</div><h2>Shared with me</h2>' + threads(c.threads.filter(t => t.shared)) + creation('project', 'projects');
}
if (!c.canSend) html += '<p class="muted">Creating and sending require signed-in send mode. This Console only reads.</p>';
c.container.innerHTML = html + '</div>';
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 selected = t ? requestRows(state(c.key).rows) : new Set();
const ids = !roomOwner() ? [] : t ? [...new Set(state(c.key).rows.filter(m => selected.has(m.id)).map(m => m.request))] : [...remembered].slice(-REQUEST_ROWS);
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 +450,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 +467,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 = '<p class="chat-loading" role="status">Reading chat…</p>';
try {
if (user) {
@@ -548,6 +583,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 +606,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 +620,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 +666,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 +713,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 +733,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);
}
+51
View File
@@ -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 = `<div class="page-head"><div><h1 tabindex="-1">Sidebar display</h1><p>These choices apply in this browser.</p></div></div><section class="panel display-settings"><form id="display-form"><fieldset><legend>Density</legend><label><input type="radio" name="density" value="comfortable"> Comfortable</label><label><input type="radio" name="density" value="compact"> Compact</label></fieldset><fieldset><legend>Sidebar order</legend><label><input type="radio" name="order" value="title-first"> Title first</label><label><input type="radio" name="order" value="contact-first"> Contact first</label></fieldset><p class="small muted">Preview uses the current sidebar destinations.</p><div id="display-preview" class="display-preview"></div><button class="btn primary" type="submit">Save</button><p id="display-status" role="status"></p></form></section>`;
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 = '<h2 id="display-leave-title">Unsaved sidebar display</h2><p>Your choices are kept while you decide.</p><p id="display-leave-status" role="status"></p><button type="button" data-choice="save">Save and continue</button> <button type="button" data-choice="edit">Keep editing</button> <button type="button" data-choice="discard">Discard and continue</button>';
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); document.getElementById('display-leave-status').textContent = ''; 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('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 };
})();
+4
View File
@@ -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; }
+93
View File
@@ -0,0 +1,93 @@
/* 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, errorState = false) {
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() : (errorState ? { error: true } : null);
} catch { return errorState ? { error: true } : 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))] : [];
if (ids.length) {
const config = await read('/api/config');
if (signedOut || generations.get(container) !== generation) return;
if (config?.requests !== true) {
container.replaceChildren(element('h3', 'Files'), element('p', 'Files are unavailable.'));
return;
}
}
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`, true)]);
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.'));
const draft = element('div', undefined, 'mosaic-files-draft');
draft.tabIndex = 0;
draft.setAttribute('role', 'region');
draft.setAttribute('aria-label', 'Product workspace, draft');
section.append(draft);
draft.append(element('h5', 'Product workspace, draft'));
if (workspace?.error) {
draft.append(element('p', 'Could not read draft workspace. Try again later.'));
} else if (!workspace || workspace.available !== true || !Array.isArray(workspace.entries)) {
draft.append(element('p', 'Draft workspace is unavailable.'));
} else {
draft.append(element('p', 'Names only. Draft files may change and are not reviewed deliveries.'));
if (!workspace.entries.length) draft.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));
}
draft.append(list);
}
if (workspace.truncated) draft.append(element('p', 'The draft list was truncated.'));
}
container.append(section);
}
}
window.MosaicFiles = Object.freeze({ mount });
})();
+6
View File
@@ -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; }
+21
View File
@@ -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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 `<div class="inbox-meta"><p>Raised ${date ? `<time datetime="${safe(date)}">${safe(date)}</time>` : 'date unknown'} · ${age}</p><p>Raised by ${fact(role)} · run ${fact(run)}</p><p>Not linked to a request</p></div>`;
}
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 `<p class="inbox-receipt-meta">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.'}</p>`;
}
window.MosaicInbox = { cardMeta: metadata, detailMeta: metadata, receipt };
})();
+7 -6
View File
@@ -1,26 +1,27 @@
<!doctype html>
<html lang="en" data-design="console" data-palette="harbor">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Mosaic Console</title>
<link rel="stylesheet" href="/shared/app.css"><link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/live.css"><link rel="stylesheet" href="/bus.css"><link rel="stylesheet" href="/chat.css"><link rel="stylesheet" href="/shell.css">
<link rel="stylesheet" href="/shared/app.css"><link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/live.css"><link rel="stylesheet" href="/bus.css"><link rel="stylesheet" href="/chat.css"><link rel="stylesheet" href="/inbox.css"><link rel="stylesheet" href="/requests.css"><link rel="stylesheet" href="/files.css"><link rel="stylesheet" href="/project-remote.css"><link rel="stylesheet" href="/shell.css">
<link rel="stylesheet" href="/editor.css"><script src="/editor.js" defer></script>
<link rel="stylesheet" href="/processing.css"><script src="/processing.js" defer></script>
<script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/chat.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
<script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/chat.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
<body>
<a class="skip" href="#main">Skip to content</a>
<header class="cmdbar"><a href="/" class="wordmark"><svg class="mark" viewBox="0 0 16 17" aria-hidden="true"><use href="/icons.svg#mark"/></svg>Mosaic<small>Console</small></a>
<header class="cmdbar"><button type="button" id="nav-open" class="btn" aria-controls="console-nav" aria-expanded="false">Menu</button><a href="/" class="wordmark"><svg class="mark" viewBox="0 0 16 17" aria-hidden="true"><use href="/icons.svg#mark"/></svg>Mosaic<small>Console</small></a>
<button type="button" class="cmdk" id="cmdk" aria-haspopup="dialog" aria-controls="cmdk-dialog"><svg class="i" aria-hidden="true"><use href="/icons.svg#i-search"/></svg><span>Go to a view, decision or task</span><kbd>Ctrl K</kbd></button>
<nav id="federation" class="federation small" aria-label="Consoles" hidden></nav>
<p class="fresh" id="fresh"><span id="fresh-board"></span><span id="fresh-bus"></span></p>
<div class="cmd-right"><label>Palette<select id="palette"></select></label><label>Appearance<select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></label><button class="btn" id="refresh" type="button">Refresh</button><button class="btn" id="pause" type="button" aria-pressed="false">Pause</button><form id="sign-out" method="post" action="/auth/logout" hidden><button class="btn" type="submit">Sign out</button></form></div></header>
<div id="processing-banner" class="processing-banner" role="alert" hidden></div>
<div id="processing-mobile"></div>
<div class="frame">
<nav class="tree" aria-label="Console"><div id="processing-controls" class="processing-controls" role="region" aria-label="Instance processing">
<div id="nav-scrim" hidden></div><nav class="rail" aria-label="Primary destinations"><a href="#/projects" aria-label="Projects" title="Projects"><svg class="i" aria-hidden="true"><use href="/icons.svg#i-inbox"/></svg><span>Projects</span></a><a href="#/inbox" aria-label="Inbox" title="Inbox"><svg class="i" aria-hidden="true"><use href="/icons.svg#i-inbox"/></svg><span>Inbox</span></a><a href="#/" aria-label="Board" title="Board"><svg class="i" aria-hidden="true"><use href="/icons.svg#i-board"/></svg><span>Board</span></a><a href="#/settings/display" aria-label="Sidebar display" title="Sidebar display"><svg class="i" aria-hidden="true"><use href="/icons.svg#i-settings"/></svg><span>Display</span></a></nav><nav class="tree" id="console-nav" aria-label="Console"><div class="drawer-head"><strong>Navigation</strong><button type="button" class="btn" id="nav-close">Close</button></div><p id="nav-overflow" class="small muted" hidden>Scroll for more destinations</p><div id="project-context" aria-label="Current project and conversation"></div><!-- Conversations slice owns this mount and its contents. --><div id="project-tree" aria-label="Project conversations"></div><div id="processing-controls" class="processing-controls" role="region" aria-label="Instance processing">
<strong>Processing</strong><p id="processing-state" role="status">Reading processing state.</p>
<button id="processing-halt" type="button" class="btn" disabled>Stop processing</button>
<button id="processing-resume" type="button" class="btn" disabled hidden>Resume processing</button>
<p id="processing-unavailable" class="processing-note small muted">Processing controls are unavailable here. Use mosaic halt or mosaic resume from the instance CLI.</p>
<p id="processing-receipt" class="processing-receipt small" aria-live="polite"></p></div>
<h2 class="tree-title">Console</h2><ul class="s1-sections" id="sections"></ul><div id="board-tree"><h2 class="tree-title">Projects</h2><div id="projects"><p class="muted">Loading projects…</p></div><p class="small muted">Select a project to filter sessions. Waiting on you always shows all projects.</p></div></nav>
<h2 class="tree-title">Console</h2><ul class="s1-sections" id="sections"></ul><div id="board-tree"><h2 class="tree-title">Board project filter</h2><div id="projects"><p class="muted">Loading projects…</p></div><p class="small muted">Select a project to filter sessions. Waiting on you always shows all projects.</p></div></nav>
<main id="main" class="content" tabindex="-1"><div id="signed-out" class="banner ref" role="alert" tabindex="-1" hidden></div><div id="bus-down" class="banner err" role="alert" hidden></div><div class="page-head" id="board-head"><div><h1>Control board</h1><p id="status" class="small muted" role="status">Loading board…</p></div><div class="filters"><label><input id="show-machine" type="checkbox">Show machine sessions</label></div></div>
<section id="held-section" aria-labelledby="held-title"><h2 id="held-title">Held roles <span id="held-count" class="count">–</span></h2><p class="small muted">The stack's seats: each role a run holds on the bus, from the bus <code>agents</code> read. Machine sessions are under Show machine sessions.</p><div id="held"><div class="skel-rows" aria-busy="true"><span class="sr-only">Reading the held roles.</span><span class="skel"></span><span class="skel"></span></div></div></section>
<div id="error" class="banner err" role="alert" hidden></div>
@@ -34,7 +35,7 @@
<div id="bus-view" hidden></div>
</main>
<aside class="inspector" id="inspector" aria-labelledby="inspector-title" hidden><div class="inspector-head"><h2 id="inspector-title" tabindex="-1">Inspector</h2><button type="button" class="btn" id="close">Close</button></div><div id="inspection"></div></aside>
</div><footer class="foot"><span id="footer">Mosaic Stack</span><span id="board-url"></span></footer><div id="announce" class="sr-only" aria-live="polite"></div>
</div><footer class="foot"><span id="footer">Mosaic Stack</span><span id="board-url"></span></footer><div id="announce" class="sr-only" aria-live="polite"></div><div id="display-announcement" class="sr-only" role="status" aria-live="polite"></div>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<dialog class="pk" id="cmdk-dialog" aria-label="Go to"><div class="pk-in"><svg class="i" aria-hidden="true"><use href="/icons.svg#i-search"/></svg><input id="cmdk-input" type="text" role="combobox" aria-expanded="true" aria-controls="cmdk-list" aria-autocomplete="list" aria-label="Go to a view, decision or task" autocomplete="off" spellcheck="false"></div><ul id="cmdk-list" class="pk-list" role="listbox" aria-label="Results"></ul><p class="pk-foot small"><kbd>↑</kbd><kbd>↓</kbd> move · <kbd>Enter</kbd> open · <kbd>Esc</kbd> close</p></dialog>
</body></html>
@@ -0,0 +1,5 @@
.project-remote { border: 1px solid var(--border, #aaa); border-radius: 4px; padding: 1rem; overflow-wrap: anywhere; }
.project-remote form { display: grid; gap: .5rem; max-width: 42rem; }
.project-remote input { box-sizing: border-box; width: 100%; min-height: 44px; }
.project-remote button { min-height: 44px; justify-self: start; }
.project-remote [role="status"] { overflow-wrap: anywhere; }
@@ -0,0 +1,93 @@
/* Owner project information mount. The server rechecks the repository key and owner. */
(() => {
'use strict';
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/;
let signedOut = false;
document.addEventListener('mosaic-signed-out', () => { signedOut = true; });
function mount({ container, projectId, repositoryKey }) {
if (!(container instanceof Element)) return;
let generation = 0, data = null, pending = false, draft = '', error = '', stale = false, canWrite = false;
const section = document.createElement('section');
section.className = 'project-remote';
container.replaceChildren(section);
document.addEventListener('mosaic-signed-out', () => { generation++; draft = ''; data = null; section.replaceChildren(); }, { once: true });
const element = (tag, content, attrs = {}) => {
const node = document.createElement(tag);
if (content !== null) node.textContent = content;
for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, value);
return node;
};
function paint() {
section.replaceChildren(element('h2', 'Repository URL', { tabindex: '-1' }));
if (signedOut) return;
if (!projectId || !ID.test(repositoryKey ?? '')) {
section.append(element('p', 'This project has no linked repository.')); return;
}
if (!data) {
section.append(element('p', error || 'Loading repository setting.', { role: 'status' })); return;
}
if (data.remote !== null) {
section.append(element('p', 'Configured, not verified. Saving does not publish.', { role: 'status', tabindex: '-1' }));
section.append(element('p', data.remote)); return;
}
section.append(element('p', 'Saving sets this repository URL only. Saving does not publish.'));
if (!canWrite || !data.firstSaveAllowed) {
section.append(element('p', 'This setting is read-only.')); return;
}
const form = element('form', null), label = element('label', 'Existing HTTPS repository URL', { for: 'project-remote-url' });
const input = element('input', null, { id: 'project-remote-url', name: 'remote', type: 'url', required: '', maxlength: '2048', 'aria-describedby': 'project-remote-message' });
input.value = draft; input.disabled = pending;
input.addEventListener('input', () => { draft = input.value; });
const button = element('button', pending ? 'Saving' : 'Save', { type: 'submit' });
button.disabled = pending || stale;
const message = element('p', error, { id: 'project-remote-message', role: 'status', 'aria-live': 'polite' });
form.append(label, input, button, message);
if (stale) {
const refresh = element('button', 'Read current setting', { type: 'button' });
refresh.disabled = pending;
refresh.addEventListener('click', () => read(true)); form.append(refresh);
}
form.addEventListener('submit', async event => {
event.preventDefault();
if (pending || stale || signedOut) return;
let parsed;
try { parsed = new URL(input.value); } catch { /* invalid input */ }
if (parsed?.username || parsed?.password) { draft = ''; input.value = ''; error = 'Repository URL must not contain credentials.'; paint(); section.querySelector('input')?.focus(); return; }
if (!parsed || parsed.protocol !== 'https:' || parsed.href !== input.value || parsed.search || parsed.hash || !/^\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(parsed.pathname)) {
error = 'Enter a valid HTTPS repository URL without credentials, query or fragment.'; paint(); section.querySelector('input')?.focus(); return;
}
pending = true; error = ''; paint();
const here = ++generation;
try {
const res = await fetch('/api/project-remotes/' + encodeURIComponent(repositoryKey), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ revision: data.revision, remote: draft }), cache: 'no-store' });
const result = await res.json();
if (here !== generation || signedOut) return;
if (!res.ok) {
stale = ['revision-conflict', 'project-remote-configured', 'outcome-unknown'].includes(result.error);
error = result.error === 'outcome-unknown' ? 'Save outcome is unknown. Read the current setting before trying again.' : stale ? 'The setting changed. Read the current setting before deciding what to do.' : result.error === 'project-remote-invalid' ? 'Enter a valid HTTPS repository URL without credentials, query or fragment.' : 'Could not save the repository URL. Your entry is retained.';
} else { data = result; draft = ''; }
} catch { if (here === generation && !signedOut) { stale = true; error = 'Save outcome is unknown. Read the current setting before trying again.'; } }
if (here === generation && !signedOut) { pending = false; paint(); (section.querySelector('button') || section.querySelector('[role=status]'))?.focus(); }
});
section.append(form);
}
async function read(reconcile = false) {
const here = ++generation; pending = true;
try {
const configRes = await fetch('/api/config', { cache: 'no-store' });
const config = await configRes.json();
if (!config.projectRemotes) throw new Error('unavailable');
const res = await fetch('/api/project-remotes/' + encodeURIComponent(repositoryKey), { cache: 'no-store' });
const result = await res.json();
if (!res.ok) throw new Error(result.error);
if (here !== generation || signedOut) return;
data = result; canWrite = !!config.projectRemoteWrites; stale = false;
error = reconcile && data.remote === null ? 'Current setting read. Review your entry before saving.' : '';
} catch { if (here === generation && !signedOut) { data = null; error = 'Repository setting is unavailable.'; } }
if (here === generation && !signedOut) { pending = false; paint(); if (reconcile) (section.querySelector('input') || section.querySelector('h2'))?.focus(); }
}
paint();
if (projectId && ID.test(repositoryKey ?? '') && !signedOut) read();
}
window.MosaicProjectRemote = { mount };
})();
+15
View File
@@ -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; }
+59 -20
View File
@@ -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 '<p class="request-prd-empty">No current PRD candidate or approved snapshot. Waiting for a PRD submission.</p>';
const c = m.candidate;
let h = `<section class="request-prd" aria-label="Exact product PRD"><p><b>Product PRD ${txt(c.prd_version)}</b></p>
<p class="small">Records commit: <code>${txt(c.records_commit)}</code><br>PRD: <a href="#/docs/${enc(c.project)}/${enc(c.prd_path)}">${txt(c.prd_path)}</a><br>PRD blob: <code>${txt(c.prd_blob)}</code><br>Confirmed intent commit: <code>${txt(c.spec_commit)}</code><br>Mode revision: ${txt(c.mode_revision)}</p>
<details><summary>PRD identity and provenance</summary><p class="small">Records commit: <code>${txt(c.records_commit)}</code><br>PRD: <a href="#/docs/${enc(c.project)}/${enc(c.prd_path)}">${txt(c.prd_path)}</a><br>PRD blob: <code>${txt(c.prd_blob)}</code><br>Confirmed intent commit: <code>${txt(c.spec_commit)}</code><br>Mode revision: ${txt(c.mode_revision)}</p></details>
<pre class="request-prd-text">${txt(m.prd)}</pre><div class="request-requirements" aria-label="Requirements">${m.requirements.map((r, i) =>
`<section class="request-requirement"><p><b>${txt(r.id)} — ${txt(r.title)}</b></p><p class="small"><code>${txt(c.requirements[i].path)}</code><br>Blob: <code>${txt(c.requirements[i].blob)}</code></p><pre>${txt(r.text)}</pre></section>`).join('')}</div>`;
`<section class="request-requirement"><p><b>${txt(r.id)} — ${txt(r.title)}</b></p><details><summary>Requirement identity</summary><p class="small"><code>${txt(c.requirements[i].path)}</code><br>Blob: <code>${txt(c.requirements[i].blob)}</code></p></details><pre>${txt(r.text)}</pre></section>`).join('')}</div>`;
if (m.snapshot.id) {
h += `<p class="request-snapshot">Approved snapshot: <code>${txt(m.snapshot.id)}</code></p>`;
if (can) h += `<div class="request-revoke">${p.ui.revokeAsk
@@ -173,26 +182,35 @@
if (!s) return p.failed ? `<section class="request-panel" data-request="${esc(p.id)}"><p class="request-unavailable">This request could not be read. <button type="button" class="btn" data-request-reload>Read again</button></p></section>` : '';
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 = `<p class="request-head"><b>Request</b> <span class="request-phase">Phase: ${txt(s.phase)}</span>${s.mode ? ` <span class="request-mode">Mode: ${txt(s.mode)}</span>` : ''}</p>`;
const returnHash = /^#\/chat\/threads\//.test(location.hash) || /^#\/projects\/threads\//.test(location.hash) ? location.hash : '';
let body = `${returnHash ? `<a class="request-return" href="${esc(returnHash)}">Return to conversation</a>` : ''}<p class="request-head"><b>Request</b> <span class="request-phase">Phase: ${txt(s.phase)}</span>${s.mode ? ` <span class="request-mode">Mode: ${txt(s.mode)}</span>` : ''}</p>`;
if (s.project) body += `<p class="request-project">Project: <code>${txt(s.project)}</code>${s.phase === 'cancelled' ? ' (kept)' : ''}</p>`;
if (p.observedAt) body += `<p class="request-observed small">Observed: <time datetime="${esc(p.observedAt)}">${txt(p.observedAt)}</time></p>`;
body += waitHtml(s);
if (Array.isArray(s.tasks) && s.tasks.length) body += `<p class="request-tasks">Tasks: ${s.tasks.filter(text).map(t => `<a href="#/tasks/${enc(t)}">${txt(t)}</a>`).join(', ')}</p>`;
if (s.phase === 'complete' && !s.delivery) body += '<p class="request-legacy-complete">Completed before delivery evidence.</p>';
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 += `<section class="request-delivery" aria-label="Reviewed delivery"><p><b>Reviewed delivery</b> — not landed or deployed.</p>
<p><a href="/api/requests/${enc(s.request)}/artifact/${enc(d.id)}" download>Download reviewed HTML</a></p>
<p class="small">Approved snapshot: <code>${txt(d.snapshot)}</code><br>Task: <a href="#/tasks/${enc(d.task_ref)}">${txt(d.task_ref)}</a><br>
<p>${prUrl ? `<a href="${esc(prUrl)}" target="_blank" rel="noopener noreferrer">Open pull request</a>` : 'PR link unavailable.'}</p><details><summary>Delivery evidence</summary><p class="small">Approved snapshot: <code>${txt(d.snapshot)}</code><br>Task: <a href="#/tasks/${enc(d.task_ref)}">${txt(d.task_ref)}</a><br>
Code commit: <a href="#/tasks/${enc(d.task_ref)}"><code>${txt(d.commit)}</code></a><br>Review request: <a href="#/tasks/${enc(d.task_ref)}"><code>${txt(d.review_request)}</code></a><br>Verdict: <a href="#/tasks/${enc(d.task_ref)}"><code>${txt(d.verdict)}</code></a><br>
Verification record: <a href="#/docs/${enc(d.project)}/${enc(d.verification.record_path)}">${txt(d.verification.record_path)}</a> at <code>${txt(d.verification.record_commit)}</code><br>
Blob: <code>${txt(d.artifact.blob)}</code><br>SHA-256: <code>${txt(d.artifact.sha256)}</code></p></section>`;
Blob: <code>${txt(d.artifact.blob)}</code><br>SHA-256: <code>${txt(d.artifact.sha256)}</code></p></details></section>`;
}
body += prdHtml(p, busy, can);
if (q) {
body += q.answer === null
? `<div class="request-question"><p>The project manager asks:</p><p class="request-question-text">${txt(q.text)}</p></div>`
: `<p class="request-question answered">Answered: ${txt(q.text)}</p>`;
: `<p class="request-question answered">Answer received: ${txt(q.text)}. Application is not established by this receipt.</p>`;
}
if (!can) body += '<p class="request-readonly small muted">Read-only: this Console cannot answer or change requests.</p>';
if (!can) body += p.stale ? '<p class="request-readonly small muted">Writes are unavailable until the request is read again.</p>' : '<p class="request-readonly small muted">Read-only: this Console cannot answer or change requests.</p>';
if (can && ui.confirm) {
const c = ui.confirm;
body += `<div class="request-confirm" role="group" aria-label="Confirm your choice"><p>${txt(c.question)}</p><p>Your choice: <b>${txt(c.text)}</b></p>
@@ -202,7 +220,7 @@
<button type="button" class="btn" data-request-unconfirm${busy}>Back</button></div>`;
} else if (can && (s.phase === 'selection' || prdDecision(s) && p.material?.candidate) && p.inbox) {
body += `<div class="request-choices" role="group" aria-label="${prdDecision(s) ? 'Review this exact product PRD version' : 'Choose how to run this request'}"><p class="small muted">Each button opens a confirmation first. Nothing is answered until you confirm.</p>${p.inbox.console.options.map(o =>
`<button type="button" class="btn" data-request-choice="${esc(o.key)}"${busy}>${txt(o.text)}</button>`).join(' ')}</div>`;
`<button type="button" class="btn" data-request-choice="${esc(o.key)}"${busy}>${s.phase === 'selection' && ['guided', 'autonomous', 'existing'].includes(o.key) ? `<b>${o.key === 'existing' ? 'Existing project' : o.key === 'guided' ? 'Guided' : 'Autonomous'}</b>: ` : ''}${txt(o.text)}</button>`).join(' ')}</div>`;
}
if (can && pickerOpen(s)) {
const picked = s.wait.code === 'request-attachment-selected' ? s.wait.subject : null;
@@ -211,6 +229,7 @@
`<button type="button" class="btn" data-request-project="${esc(x.project)}" aria-pressed="${x.project === picked}"${busy}>${txt(x.name)} <small>(${txt(x.project)})</small></button>`).join(' ')}` : '<p>No project is available to you for this request.</p>'}</div>`
: '<p class="request-picker-note">The projects you may use could not be read. Use Read again.</p>';
}
if (p.stale && openQuestion(s) && ui.draft) body += `<p>Unsent answer retained:</p><pre class="request-retained-draft">${txt(ui.draft)}</pre>`;
if (can && openQuestion(s)) {
const lost = p.stored && p.stored.question === s.question.id;
body += `<form class="request-answer" novalidate><label for="chat-request-answer-${esc(p.id)}">Your answer</label>
@@ -231,8 +250,9 @@
<button type="button" class="btn" data-request-cancel-confirm${busy}>Yes, cancel the request</button> <button type="button" class="btn" data-request-cancel-keep${busy}>Keep it going</button>`
: `<button type="button" class="btn" data-request-cancel${busy}>Cancel this request</button>`}</div>`;
}
body += `<section class="request-files" aria-label="Files"><h3>Files</h3><div data-request-files><p>Files unavailable.</p></div></section>`;
body += `<p class="request-receipt small" role="status" tabindex="-1">${txt(ui.receipt)}</p>`;
body += `<p class="request-foot small">${p.stale ? 'Could not refresh; showing the last state. ' : ''}<button type="button" class="btn" data-request-reload${busy}>Read again</button></p>`;
body += `<p class="request-foot small">${p.stale ? 'Could not refresh; showing the last state. ' : ''}${p.reading ? '<span role="status">Refreshing request…</span>' : `<button type="button" class="btn" data-request-reload${busy}>Read again</button>`}</p>`;
return `<section class="request-panel" data-request="${esc(p.id)}" aria-label="Request">${body}</section>`;
}
@@ -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 ? `<section class="request-project-summary" aria-label="Requests"><h3>Requests</h3>${valid.map(s => `<p>Request <code>${txt(s.request)}</code> · Phase: ${txt(s.phase)}${s.snapshot ? ' · PRD approved' : ''}</p>`).join('')}</section>` : '<p>Request information unavailable.</p>';
}
window.MosaicRequests = Object.freeze({ mount, forget, projectSummary });
})();
+51 -1
View File
@@ -136,7 +136,7 @@ body.on-bus #board-refused{display:none}
.tree,.inspector{top:var(--bar-h);height:calc(100vh - var(--bar-h) - var(--foot-h))}
}
/* The live region after the footer sits at its static place, one pixel past the screen; pin it so it adds no scroll. */
#announce{top:0;left:0}
#announce,#display-announcement{position:fixed;top:0;left:0}
/* Breakpoints. 760 to 959 px keeps the side column (console.css drops it at 959). */
@media (min-width:760px) and (max-width:959px){
@@ -175,3 +175,53 @@ 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}
.content .btn.primary{min-height:var(--action-target)}
[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:flex;visibility:hidden;pointer-events: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{visibility:visible;pointer-events:auto}
#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}
}
/* W15 navigation: the rail is a sibling of the project/sidebar column. */
.rail{display:none}
@media (min-width:960px){
.frame,body.on-bus .frame,body.has-inspector .frame,body.on-bus.has-inspector .frame{grid-template-columns:var(--rail-width) var(--sidebar-width) minmax(0,1fr)}
body.has-inspector .frame,body.on-bus.has-inspector .frame{grid-template-columns:var(--rail-width) var(--sidebar-width) minmax(0,1fr) 340px}
.frame.frame > .tree{position:sticky;width:auto}
.frame.frame > .content{grid-column:3}
body{--processing-nav:calc(var(--rail-width) + var(--sidebar-width))}
.rail{position:sticky;top:var(--bar-h);height:calc(100vh - var(--bar-h) - var(--foot-h));display:flex;flex-direction:column;gap:4px;padding:10px 6px;border-right:1px solid var(--line);background:var(--surface)}
.rail a{display:flex;min-height:58px;align-items:center;justify-content:center;flex-direction:column;gap:3px;border-radius:var(--r);color:var(--text);font-size:11px;text-decoration:none}
.rail a:hover,.rail a[aria-current=page]{background:var(--raised);color:var(--strong)}
.rail a[aria-current=page]{box-shadow:inset 3px 0 var(--action)}
}
#project-context:not(:empty){border-bottom:1px solid var(--line);padding:0 4px 10px;overflow-wrap:anywhere}
.context-project{display:block;min-height:var(--action-target);padding:10px 0;color:var(--action);font-weight:700}
.context-conversation{display:block;min-height:var(--action-target);padding:10px 0;margin:0;color:var(--strong);overflow-wrap:anywhere}
#project-tree:not(:empty){border-bottom:1px solid var(--line);padding-bottom:10px}
.project-archive-control,.project-archived-filter{border:1px solid var(--border);background:var(--surface);color:var(--text);padding:8px 12px}
.project-archive-control:hover,.project-archived-filter:hover{background:var(--raised)}
a{ text-underline-offset: .15em }
button:disabled,[aria-disabled=true]{cursor:not-allowed}
@media (max-width:759px){.tree{width:100%;overscroll-behavior:contain;scrollbar-gutter:stable}#console-nav>*{flex:0 0 auto}}
+6 -2
View File
@@ -6,10 +6,14 @@
--font: "Manrope", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--mono: "JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
--r: 6px;
--r-lg: 10px;
--r-table: 8px;
--r-lg: 6px;
--r-table: 6px;
--pad: 16px;
--gap: 12px;
/* Shared W15 geometry and interaction tokens. */
--rail-width: 88px;
--sidebar-width: 226px;
--action-target: 44px;
}
/* Harbor: the default palette */
+1 -1
View File
@@ -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 });
+17 -4
View File
@@ -20,11 +20,11 @@ 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|workspace|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',
});
const READ_VERBS = Object.freeze({ get: 'request.get.console', projects: 'request.projects.console', draft: 'request.draft.console' });
const READ_VERBS = Object.freeze({ get: 'request.get.console', projects: 'request.projects.console', draft: 'request.draft.console', workspace: 'request.workspace.console' });
// The exact key set of each write body. A key outside it, such as `request`, `business` or `subject`,
// is refused: the route and the session say who and what.
const FIELDS = Object.freeze({
@@ -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;
@@ -165,6 +165,7 @@ export async function requestHTTP(req, res, { path, requestRead, requestWrite, s
const line = tail => log(`${new Date().toISOString()} request user=${user} request=${JSON.stringify(String(id ?? '').slice(0, 64))} action=${action}${tail}`);
const refuse = (code, extra = '') => { line(`${extra} error=${code}`); return requestRefuse(res, code); };
if (id === null || !ID.test(id) || (action !== 'artifact' && rawDelivery !== undefined)) return refuse('request-not-found');
if (action === 'workspace' && (req.url?.includes('?') || Number(req.headersDistinct['content-length']?.[0] ?? 0) !== 0 || req.headersDistinct['transfer-encoding'])) return refuse('invalid-request');
let delivery = null;
if (action === 'artifact') {
try { delivery = decodeURIComponent(rawDelivery ?? ''); } catch {}
@@ -189,14 +190,26 @@ 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];
if (action === 'draft' && !verbs.includes(verb)) return refuse('draft-unavailable');
if (action === 'workspace' && !verbs.includes(verb)) return refuse('unavailable');
if (live() !== session) return refuse('sign-in-required');
let reply;
try { reply = await requestRead(verb, { request: id }, { subject }); } catch (err) { return refuse(busCode(err, 'unavailable')); }
if (action === 'workspace') {
if (live() !== session) return refuse('sign-in-required');
const validName = name => typeof name === 'string' && name.length <= 4096 && name.split('/').every(p => p && p !== '.' && p !== '..' && !p.startsWith('.') && !/[\x00-\x1f\x7f\\]/.test(p));
if (!reply || Object.keys(reply).sort().join(',') !== 'available,contentAvailable,entries,observedAt,truncated' || typeof reply.available !== 'boolean' || typeof reply.truncated !== 'boolean' || reply.contentAvailable !== false ||
typeof reply.observedAt !== 'string' || !Number.isFinite(Date.parse(reply.observedAt)) || !Array.isArray(reply.entries) || reply.entries.length > 100 ||
(!reply.available && (reply.entries.length || reply.truncated)) || reply.entries.some(e => !e || Object.keys(e).join(',') !== 'name' || !validName(e.name))) return refuse('unavailable');
line('');
return json(res, 200, reply);
}
if (action === 'projects') {
if (!picker(reply)) return refuse('unavailable');
line('');
+18 -2
View File
@@ -14,6 +14,7 @@ import { chatHTTP } from './chat-http.mjs';
import { processingHTTP } from './processing-http.mjs';
import { DECISION_ROUTE, decisionHTTP, decisionRefuse, createConfirmations } from './decision-http.mjs';
import { REQUEST_ROUTE, requestHTTP, requestRefuse } from './request-http.mjs';
import { PROJECT_REMOTE_ROUTE, projectRemoteHTTP } from './project-remote-http.mjs';
export const DEFAULT_BOARD = 'http://127.0.0.1:7331';
export function isLoopback(host) {
@@ -33,13 +34,21 @@ const files = new Map([
['/', ['index.html', 'text/html; charset=utf-8']],
['/settings/model-proxy', ['proxy.html', 'text/html; charset=utf-8']],
['/proxy.js', ['proxy.js', 'text/javascript; charset=utf-8']],
['/project-remote.js', ['project-remote.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']]),
['/display.js', ['display.js', 'text/javascript; charset=utf-8']],
['/inbox.js', ['inbox.js', 'text/javascript; charset=utf-8']],
['/inbox.css', ['inbox.css', 'text/css; charset=utf-8']],
['/requests.css', ['requests.css', '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.
['/render.mjs', ['../../../library/src/render.mjs', 'text/javascript; charset=utf-8']],
['/editor.css', ['editor.css', 'text/css; charset=utf-8']],
['/icons.svg', ['icons.svg', 'image/svg+xml']],
['/project-remote.css', ['project-remote.css', 'text/css; charset=utf-8']],
...[400, 500, 600, 700].map(w => [`/assets/fonts/manrope-${w}.woff2`, [`assets/fonts/manrope-${w}.woff2`, 'font/woff2']]),
...[400, 500, 700].map(w => [`/assets/fonts/jetbrains-mono-${w}.woff2`, [`assets/fonts/jetbrains-mono-${w}.woff2`, 'font/woff2']]),
]);
@@ -729,7 +738,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
// alone stays open on a loopback Host for the status and release gates (Q-C).
if (sso !== null && !named && !(path === '/healthz' && req.method === 'GET')) return json(res, 403, { error: 'public Host required' });
// No CORS. JSON content type and same-origin checks keep browser forms out.
const decisionPath = DECISION_ROUTE.test(path), requestPath = REQUEST_ROUTE.test(path);
const decisionPath = DECISION_ROUTE.test(path), requestPath = REQUEST_ROUTE.test(path), remotePath = PROJECT_REMOTE_ROUTE.test(path);
if (req.headers.origin && req.headers.origin !== (named ? (sso ? sso.publicOrigin : `https://${preview.host}`) : `http://${req.headers.host}`))
return decisionPath ? decisionRefuse(res, 'cross-origin') : requestPath ? requestRefuse(res, 'cross-origin') : json(res, 403, { error: 'cross-origin request refused' });
let signer = preview, ssoSession = null;
@@ -740,6 +749,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (!session && path !== '/healthz') {
if (decisionPath) return decisionRefuse(res, 'sign-in-required');
if (requestPath) return requestRefuse(res, 'sign-in-required');
if (remotePath) return json(res, 401, { error: 'sign-in-required' });
if (path.startsWith('/api/') || req.method !== 'GET') return json(res, 401, { error: 'sign-in required' });
res.writeHead(303, { location: `/auth/login?return=${encodeURIComponent(path + search)}`, 'cache-control': 'no-store' });
return res.end();
@@ -794,6 +804,12 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
return requestHTTP(req, res, { path, requestRead, requestWrite: send ? requestWrite : null,
session: ssoSession, live: () => sso.session(req), origin: sso.publicOrigin, log });
}
if (remotePath) {
if (tester || granted || !named || sso === null) return json(res, 403, { error: 'owner-only' });
return projectRemoteHTTP(req, res, { path, read: requestRead, write: send ? requestWrite : null,
session: ssoSession, live: () => sso.session(req), origin: sso.publicOrigin });
}
if (tester && ['/project-remote.js', '/project-remote.css', '/files.js', '/files.css'].includes(path)) return json(res, 403, { error: 'owner-only' });
// Row 194 (#1692, D4.1): the tester also launches and terminates.
if (tester && !['GET', 'HEAD'].includes(req.method) && !['/api/instances', '/api/routes', '/api/name', '/api/agent/launch', '/api/agent/terminate'].includes(path) && !TESTER_CHAT_WRITE.test(path))
return json(res, 403, { error: 'read-only', sent: false });
@@ -927,7 +943,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
// `send`: the page shows the composer only where a send can pass (the public name, --send on).
// `edit`: the editor shows Save only where a save can pass, the same test as the route's.
// `user` and `federation` (FED R6) only when there is one, so the owner's config is unchanged.
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(!tester && !granted && named && sso !== null && requestRead ? { requests: true, requestWrites: Boolean(send && requestWrite) } : {}), ...(hub ? { federation: hub } : {}), send: !tester && Boolean(send) && named, decide: !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !tester && !granted && Boolean(send) && Boolean(projectSave) && named });
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(!tester && !granted && named && sso !== null && requestRead ? { requests: true, requestWrites: Boolean(send && requestWrite), workspaceFiles: true, projectRemotes: true, projectRemoteWrites: Boolean(send && requestWrite) } : {}), ...(hub ? { federation: hub } : {}), send: !tester && Boolean(send) && named, decide: !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !tester && !granted && Boolean(send) && Boolean(projectSave) && named });
if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) });
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
const file = files.get(path);
@@ -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('<script>window.executed = true</script>');
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);
});
+1 -1
View File
@@ -19,7 +19,7 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
await b.evaluate('document.querySelector("#hide-offline").click()');
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4);
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/chat.js', '/agents.js', '/bus.js', '/requests.js']);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/chat.js', '/agents.js', '/bus.js', '/requests.js']);
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/processing.js"]').length`), 1);
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/chat.js"]').length`), 1);
assert.equal(await b.evaluate('!!window.injected'), false);
+2 -2
View File
@@ -221,10 +221,10 @@ test('chat browser: list/create named projects and threads, hostile labels inert
const projectText = await b.evaluate('document.querySelector(".chat-projects").textContent');
assert.doesNotMatch(projectText, /[\u202a-\u202e\u2066-\u2069]/u);
assert.match(projectText, /\\u202e/);
assert.equal(await b.evaluate(`!!document.querySelector('#sections a[href="#/chat"]')`), true);
assert.equal(await b.evaluate(`!!document.querySelector('#sections a[href="#/projects"]')`), true);
await b.key('k', 'KeyK', 2);
await wait('document.querySelector("#cmdk-dialog").open');
assert.equal(await b.evaluate(`!!document.querySelector('#cmdk-list [data-href="#/chat"]')`), true);
assert.equal(await b.evaluate(`!!document.querySelector('#cmdk-list [data-href="#/projects"]')`), true);
await b.key('Escape');
await set('#chat-project-name', 'new project');
await set('#chat-repo', 'stack');
@@ -0,0 +1,59 @@
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=[];
document.addEventListener('DOMContentLoaded', () => {
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};
}
@@ -0,0 +1,28 @@
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/config') return new Response(JSON.stringify({requests:true}), {status: 200, headers: {'content-type':'application/json'}});
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 };
}
@@ -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 };
}
@@ -0,0 +1,47 @@
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../../business/tests/helpers.mjs';
import { loadBusiness } from '../../../business/src/business.mjs';
import { resolveInstance } from '../../../business/src/resolve.mjs';
import { busBusiness } from '../../../bus/src/business.mjs';
import { startBroker } from '../../../bus/src/runtime.mjs';
import { serve } from '../../../bus/src/server.mjs';
import { Client } from '../../../bus/src/client.mjs';
import { startServer } from '../../src/serve.mjs';
import { close } from '../fixture.mjs';
// Ephemeral organization, real runtime writer, broker socket and HTTP listener. The proof
// verifier and SSO session are fixture authorities; no live service or network is involved.
export async function realRemoteFixture(t, mutate = () => {}) {
const root = mkdtempSync(join(tmpdir(), 'remote-real-'));
const rolesDir = rolesCopy(root), system = systemFor(root);
const doc = businessDoc(root, 'demo'); mutate(doc);
const file = writeJson(join(root, 'businesses/demo.json'), doc);
mkdirSync(join(root, 'data'), { mode: 0o700 });
const form = () => {
const business = loadBusiness('demo', { dir: root, rolesDir });
const resolved = Object.fromEntries(Object.keys(business.roles).map(instance => [instance, resolveInstance({ system, business, instance })]));
return busBusiness(business, resolved);
};
const start = () => startBroker({ dataRoot: join(root, 'data'), businesses: { demo: form() }, organization: { dir: root, rolesDir, system } });
let runtime = await start();
let socketServer;
const path = join(root, 'fixture.sock');
const attach = async () => {
socketServer = await serve({ broker: runtime.broker, path, peerReader: { read: async () => ({}), close: () => {} },
authenticateConsole: proof => runtime.broker.bindConsole({ business: 'demo', human: 'jason', subject: proof.subject }) });
};
await attach();
const subject = { provider: 'fixture', iss: 'https://issuer.example', sub: 'owner' };
const session = { ...subject, name: 'jason' };
let active = true;
const sso = { publicOrigin: '', publicHost: '', formTargets: () => [], session: () => active ? session : null, handle: () => {}, health: () => ({}) };
const transport = (verb, args, ctx) => new Client({ path, console: { subject: ctx.subject } }).call(verb, args);
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, send: async () => ({}), requestRead: transport, requestWrite: transport, log: () => {} });
const base = `http://127.0.0.1:${web.address().port}`;
sso.publicOrigin = base; sso.publicHost = new URL(base).host;
t.after(async () => { await close(web); await socketServer.close(); await runtime.close(); rmSync(root, { recursive: true, force: true }); });
const restart = async () => { await socketServer.close(); await runtime.close(); runtime = await start(); await attach(); };
return { root, file, base, path, session, subject, restart, signOut: () => { active = false; }, broker: () => runtime.broker };
}
@@ -0,0 +1,43 @@
import { fixture } from '../fixture.mjs';
import { browser } from '../browser.mjs';
// The conversations slice supplies the real mount point at integration. This fixture
// installs one in the actual Console document and stubs only its network boundary.
export async function remoteFixture(t, width) {
const f = await fixture(); t.after(() => f.close());
const b = await browser({ machine: false }); t.after(() => b.close());
const errors = [], network = [];
b.on('Runtime.exceptionThrown', p => errors.push(p.exceptionDetails?.exception?.description ?? p.exceptionDetails?.text ?? 'exception'));
b.on('Fetch.requestPaused', p => {
if (p.request.url.startsWith(f.base + '/')) b.call('Fetch.continueRequest', { requestId: p.requestId }).catch(() => {});
else { network.push(p.request.url); b.call('Fetch.failRequest', { requestId: p.requestId, errorReason: 'BlockedByClient' }).catch(() => {}); }
});
await b.call('Fetch.enable', { patterns: [{ urlPattern: '*' }] });
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `(() => {
window.__remote = { project: 'stack', remote: null, revision: '${'a'.repeat(64)}', firstSaveAllowed: true };
window.__writes = []; window.__mode = 'success'; window.__writeEnabled = true;
window.fetch = async (url, options = {}) => {
const path = String(url), method = options.method || 'GET';
const reply = (data, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json' } });
if (path === '/api/config') return reply({ projectRemotes: true, projectRemoteWrites: window.__writeEnabled });
if (path === '/api/project-remotes/stack') {
if (method === 'GET') return reply(window.__remote);
window.__writes.push(JSON.parse(options.body));
if (window.__mode === 'hold') await new Promise(resolve => window.__release = resolve);
if (window.__mode === 'stale') return reply({ error: 'revision-conflict' }, 409);
if (window.__mode === 'unknown') return reply({ error: 'outcome-unknown' }, 503);
if (window.__mode === 'fail') return reply({ error: 'project-remote-invalid' }, 400);
window.__remote = { ...window.__remote, remote: window.__writes.at(-1).remote, revision: '${'b'.repeat(64)}', firstSaveAllowed: false };
return reply(window.__remote);
}
return reply({ error: 'unexpected-api' }, 503);
};
})();` });
await b.viewport(width, 1000);
await b.navigate(f.base + '/#/projects/10000000-0000-4000-8000-000000000001/settings');
await b.evaluate(`new Promise((resolve,reject)=>{const link=document.createElement('link');link.rel='stylesheet';link.href='/project-remote.css';document.head.append(link);const script=document.createElement('script');script.src='/project-remote.js';script.onload=resolve;script.onerror=reject;document.head.append(script)})`);
await b.evaluate(`(()=>{const main=document.querySelector('#main');for(const child of main.children)child.style.display='none';main.hidden=false;const h=document.createElement('h1');h.textContent='Project information';const slot=document.createElement('div');slot.id='remote-fixture';main.append(h,slot);return true})()`);
const mount = async (repositoryKey = 'stack') => b.evaluate(`MosaicProjectRemote.mount({container:document.querySelector('#remote-fixture'),projectId:'10000000-0000-4000-8000-000000000001',repositoryKey:${JSON.stringify(repositoryKey)}}),true`);
const wait = expression => b.evaluate(`(async()=>{const until=Date.now()+10000;while(!(${expression})){if(Date.now()>until)throw Error('timed out');await new Promise(r=>setTimeout(r,20))}return true})()`);
return { b, f, mount, wait, errors, network };
}
@@ -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));
document.addEventListener('DOMContentLoaded', () => { window.MosaicFiles = { mount({container, requestIds}) { window.__fileCalls.push(requestIds); container.textContent = 'Fixture Files mount'; } }; });
document.addEventListener('DOMContentLoaded', () => { const s=document.createElement('style'); s.textContent=${JSON.stringify(css)}; document.head.append(s); });
window.fetch = async (url, init = {}) => {
const path = String(url), method = init.method || 'GET';
const json = (v, status=200) => new Response(JSON.stringify(v), {status, headers:{'content-type':'application/json'}});
if (method !== '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 = `#/projects/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};
}
@@ -0,0 +1,67 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { projectRemoteHTTP } from '../src/project-remote-http.mjs';
const origin = 'https://console.test', revision = 'a'.repeat(64);
const subject = { provider: 'oidc', iss: 'https://id.test', sub: 'owner' };
function setup() {
const session = { ...subject, name: 'owner' }, calls = [];
let current = session;
const transport = async (verb, args, context) => {
calls.push({ verb, args, context });
return { project: args.project, remote: verb.includes('.set.') ? args.remote : null, revision, firstSaveAllowed: !verb.includes('.set.') };
};
async function run({ method = 'GET', body = {}, headers = {}, read = transport, write = transport, live = () => current } = {}) {
const h = { origin: [origin], 'sec-fetch-site': ['same-origin'], 'content-type': ['application/json'], ...headers };
for (const k of Object.keys(h)) if (h[k] === null) delete h[k];
const req = { method, headersDistinct: h, async *[Symbol.asyncIterator]() { yield Buffer.from(JSON.stringify(body)); } };
const res = { writeHead(status, headers) { this.status = status; this.headers = headers; }, end(value) { this.value = JSON.parse(value); } };
await projectRemoteHTTP(req, res, { path: '/api/project-remotes/stack', read, write, session, live, origin });
return res;
}
return { calls, run, logout: () => { current = null; } };
}
test('only the trusted session subject and exact route fields reach the Console transport', async () => {
const s = setup();
assert.equal((await s.run()).status, 200);
const result = await s.run({ method: 'POST', body: { revision, remote: 'https://git.example/owner/repo' } });
assert.equal(result.status, 200);
assert.deepEqual(s.calls, [
{ verb: 'project.remote.get.console', args: { project: 'stack' }, context: { subject } },
{ verb: 'project.remote.set.console', args: { project: 'stack', revision, remote: 'https://git.example/owner/repo' }, context: { subject } },
]);
});
test('method, origin, payload and expired session fail before transport', async () => {
const s = setup(), body = { revision, remote: 'https://git.example/owner/repo' };
assert.equal((await s.run({ method: 'PUT' })).status, 405);
for (const headers of [{ origin: ['https://other.test'] }, { origin: null }, { 'sec-fetch-site': ['same-site'] }, { 'sec-fetch-site': null }, { 'content-type': ['text/plain'] }])
assert.notEqual((await s.run({ method: 'POST', body, headers })).status, 200);
for (const bad of [{ revision }, { remote: body.remote }, { ...body, business: 'other' }, { ...body, projects: {} }, { ...body, credentials: 'secret' }, { ...body, publish: true }, { ...body, revision: 'bad' }])
assert.equal((await s.run({ method: 'POST', body: bad })).status, 400);
s.logout(); assert.equal((await s.run({ method: 'POST', body })).status, 401);
assert.deepEqual(s.calls, []);
});
test('refusals and malformed replies never echo credential-bearing URLs', async () => {
const s = setup(), secret = 'https://name:[email protected]/owner/repo';
const write = async () => { const e = new Error(secret); e.code = 'project-remote-invalid'; throw e; };
const refused = await s.run({ method: 'POST', body: { revision, remote: secret }, write });
assert.equal(refused.status, 400); assert.ok(!JSON.stringify(refused.value).includes('secret'));
const read = async () => ({ project: 'stack', revision, remote: secret, firstSaveAllowed: false });
const invalid = await s.run({ read });
assert.equal(invalid.status, 503); assert.ok(!JSON.stringify(invalid.value).includes('secret'));
});
test('an expiry during the body read prevents the write from starting', async () => {
const s = setup(); let checks = 0;
const out = await s.run({ method: 'POST', body: { revision, remote: 'https://git.example/owner/repo' }, live: () => { checks++; return null; } });
assert.equal(out.status, 401); assert.equal(checks, 1); assert.deepEqual(s.calls, []);
});
test('a write admitted before sign-out returns its receipt if the session ends during the call', async () => {
const s = setup();
const write = async (verb, args) => {
s.logout();
return { project: args.project, revision, remote: args.remote, firstSaveAllowed: false };
};
const out = await s.run({ method: 'POST', body: { revision, remote: 'https://git.example/owner/repo' }, write });
assert.equal(out.status, 200); assert.equal(out.value.remote, 'https://git.example/owner/repo');
});
@@ -0,0 +1,169 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { request } from 'node:http';
import childProcess from 'node:child_process';
import https from 'node:https';
import { syncBuiltinESMExports } from 'node:module';
import { Credentials } from '../../bus/src/credentials.mjs';
import { Broker } from '../../bus/src/broker.mjs';
import { Client } from '../../bus/src/client.mjs';
import { realRemoteFixture } from './helpers/w15-project-remote-real.mjs';
const url = 'https://git.example/owner/repo.git';
function call(f, method, body, headers = {}) {
return new Promise((resolve, reject) => {
const data = body === undefined ? undefined : JSON.stringify(body);
const req = request(f.base + '/api/project-remotes/stack', { method, headers: { ...(data ? { origin: f.base, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } : {}), ...headers } }, res => {
let text = ''; res.on('data', chunk => { text += chunk; }); res.on('end', () => resolve([res.statusCode, JSON.parse(text)]));
});
req.on('error', reject); req.setTimeout(10000, () => req.destroy(new Error('fixture request timed out'))); req.end(data);
});
}
const get = f => call(f, 'GET');
const post = (f, body, headers) => call(f, 'POST', body, headers);
test('owner saves over HTTP and the broker socket, then reads after runtime restart', async t => {
const f = await realRemoteFixture(t), [status, before] = await get(f);
assert.equal(status, 200); assert.equal(before.remote, null);
// Fail the save if it attempts a forge call, process/Git command, credential mutation,
// or agent launch. Install only after fixture startup and restore before shutdown.
const effects = { process: 0, forge: 0, credential: 0, launch: 0 };
const originals = [];
const fence = (object, key, kind) => {
originals.push([object, key, object[key]]);
object[key] = () => { effects[kind]++; throw Error('unexpected ' + kind); };
};
for (const key of ['spawn', 'spawnSync', 'exec', 'execSync', 'execFile', 'execFileSync', 'fork']) fence(childProcess, key, 'process');
for (const key of ['request', 'get']) fence(https, key, 'forge');
for (const key of ['issue', 'revoke', 'remove', 'unlinkStore', 'writeModel', 'register', 'unregister', 'registerModel']) fence(Credentials.prototype, key, 'credential');
fence(Broker.prototype, 'bindLaunch', 'launch');
fence(globalThis, 'fetch', 'forge'); syncBuiltinESMExports();
let savedStatus, saved;
try { [savedStatus, saved] = await post(f, { revision: before.revision, remote: url }); }
finally { for (const [object, key, original] of originals) object[key] = original; syncBuiltinESMExports(); }
assert.deepEqual(effects, { process: 0, forge: 0, credential: 0, launch: 0 });
assert.equal(savedStatus, 200, JSON.stringify(saved)); assert.equal(saved.remote, url);
assert.equal(JSON.parse(readFileSync(f.file)).projects.stack.remote, url);
await f.restart();
const [reloadStatus, reloaded] = await get(f);
assert.equal(reloadStatus, 200); assert.deepEqual(reloaded, saved);
const [existingStatus, existing] = await post(f, { revision: reloaded.revision, remote: 'https://git.example/other/repo' });
assert.equal(existingStatus, 409); assert.equal(existing.error, 'project-remote-configured');
});
test('sign-out, stale, credentials, extra fields and cross-origin do not write', async t => {
const f = await realRemoteFixture(t), [, before] = await get(f), original = readFileSync(f.file);
const body = { revision: before.revision, remote: url };
for (const [b, headers, expected] of [
[{ ...body, revision: 'a'.repeat(64) }, {}, 'revision-conflict'],
[{ ...body, remote: 'https://user:[email protected]/owner/repo' }, {}, 'project-remote-invalid'],
[{ revision: body.revision }, {}, 'invalid-request'], [{ ...body, publish: true }, {}, 'invalid-request'],
[body, { origin: 'https://other.example' }, 'cross-origin request refused'],
]) {
const [status, result] = await post(f, b, headers);
assert.ok(status >= 400); assert.equal(result.error, expected); assert.ok(!JSON.stringify(result).includes('secret')); assert.deepEqual(readFileSync(f.file), original);
}
f.signOut(); const [status, result] = await post(f, body);
assert.equal(status, 401); assert.equal(result.error, 'sign-in-required'); assert.deepEqual(readFileSync(f.file), original);
});
test('real SSO refuses viewer and collaborator and sign-out prevents the saved write', async t => {
const { fakeOidc } = await import('./helpers/fake-oidc.mjs');
const { createSso, SESSION_COOKIE } = await import('../src/sso.mjs');
const { consoleAccess } = await import('../src/access.mjs');
const { startServer } = await import('../src/serve.mjs');
const { close } = await import('./fixture.mjs');
const op = await fakeOidc(); t.after(() => op.close());
const issuer = op.issuer('a'), origin = 'https://console.example.test';
const access = { accessVersion: 1, revision: 1, users: [
{ id: 'alice', home: 'local', subjects: [{ provider: 'a', sub: 'alice-sub' }] },
{ id: 'bob', home: 'local', subjects: [{ provider: 'a', sub: 'bob-sub' }] },
], grants: [
{ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'viewer' },
{ id: 'g-2', user: 'bob', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator' },
] };
const config = { publicOrigin: origin, providers: [{ id: 'a', issuer: issuer.issuer, clientId: issuer.clientId, clientSecret: { slot: 'a' }, algs: ['RS256'], subjects: [{ sub: 'owner-sub', name: 'jason' }] }] };
const sso = await createSso(config, { secrets: new Map([['a', issuer.clientSecret]]), fetch: op.fetch, users: consoleAccess({ load: () => access, business: () => 'demo' }), log: () => {} });
t.after(() => sso.close());
const f = await realRemoteFixture(t), original = readFileSync(f.file);
let writes = 0;
const transport = (verb, args, ctx) => new Client({ path: f.path, console: { subject: ctx.subject } }).call(verb, args);
const read = transport;
const write = (verb, args, ctx) => { writes++; return transport(verb, args, ctx); };
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, send: async () => ({}), requestRead: read, requestWrite: write, log: () => {} });
t.after(() => close(web));
const send = (method, path, cookie, body, extraHeaders = {}) => new Promise((resolve, reject) => {
const data = body === undefined ? undefined : JSON.stringify(body);
const req = request({ host: '127.0.0.1', port: web.address().port, method, path, headers: { host: 'console.example.test', ...(cookie ? { cookie } : {}), ...(data ? { origin, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } : {}), ...extraHeaders } }, res => {
let text = ''; res.on('data', chunk => { text += chunk; }); res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: text && (res.headers['content-type'] ?? '').startsWith('application/json') ? JSON.parse(text) : text }));
}); req.on('error', reject); req.setTimeout(10000, () => req.destroy(Error('request timed out'))); req.end(data);
});
const signIn = async sub => {
const start = await send('GET', '/auth/login');
const cookie = start.headers['set-cookie'][0].split(';')[0];
const grant = op.grant(issuer, start.headers.location, { sub });
const q = new URLSearchParams({ code: grant.code, state: new URL(start.headers.location).searchParams.get('state') });
const result = await send('GET', '/auth/callback?' + q, cookie);
return result.headers['set-cookie'].find(c => c.startsWith(SESSION_COOKIE + '=') && !c.includes('Max-Age=0')).split(';')[0];
};
const body = { revision: 'a'.repeat(64), remote: url };
for (const sub of ['alice-sub', 'bob-sub']) {
const cookie = await signIn(sub);
for (const method of ['GET', 'POST']) {
const response = await send(method, '/api/project-remotes/stack', cookie, method === 'POST' ? body : undefined);
assert.equal(response.status, 403); assert.equal(response.body.error, 'owner-only');
}
}
// A CLI human or agent cannot turn an unauthenticated HTTP request into an owner session.
for (const headers of [{ 'x-user': 'jason' }, { 'x-agent': 'pm' }]) for (const method of ['GET', 'POST']) {
const response = await send(method, '/api/project-remotes/stack', null, method === 'POST' ? body : undefined, headers);
assert.equal(response.status, 401); assert.equal(response.body.error, 'sign-in-required');
}
const owner = await signIn('owner-sub');
const readOwner = await send('GET', '/api/project-remotes/stack', owner);
assert.equal(readOwner.status, 200); assert.equal(readOwner.body.remote, null);
assert.equal((await send('POST', '/auth/logout', owner, {})).status, 303);
const after = await send('POST', '/api/project-remotes/stack', owner, { ...body, revision: readOwner.body.revision });
assert.equal(after.status, 401); assert.equal(after.body.error, 'sign-in-required'); assert.equal(writes, 0);
assert.deepEqual(readFileSync(f.file), original);
});
test('tester listener refuses both remote methods', async t => {
const { mkdtempSync, rmSync } = await import('node:fs');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const { startServer } = await import('../src/serve.mjs');
const { close } = await import('./fixture.mjs');
const root = mkdtempSync(join(tmpdir(), 'remote-tester-')), socket = join(root, 'tester.sock');
const server = await startServer({ tester: true, testerSocket: socket, board: 'http://127.0.0.1:9', log: () => {} });
t.after(async () => { await close(server); rmSync(root, { recursive: true, force: true }); });
for (const method of ['GET', 'POST']) {
const result = await new Promise((resolve, reject) => {
const req = request({ socketPath: socket, path: '/api/project-remotes/stack', method, headers: { host: 'mosaic-tester.invalid' } }, res => { res.resume(); res.on('end', () => resolve(res.statusCode)); });
req.on('error', reject); req.end();
});
assert.equal(result, 403);
}
for (const path of ['/project-remote.js', '/project-remote.css', '/files.js', '/files.css']) {
const result = await new Promise((resolve, reject) => {
const req = request({ socketPath: socket, path, headers: { host: 'mosaic-tester.invalid' } }, res => { res.resume(); res.on('end', () => resolve(res.statusCode)); });
req.on('error', reject); req.end();
});
assert.equal(result, 403, path);
}
});
test('library targets and unrelated project mapping drift are refused', async t => {
const { mappingEntry } = await import('../../library/tests/helpers.mjs');
const { writeFileSync } = await import('node:fs');
const f = await realRemoteFixture(t, doc => { doc.projects.books = mappingEntry(); doc.roles.chief.credentials = { gitea: { ...doc.roles.coder.credentials.gitea } }; });
const library = await new Promise((resolve, reject) => {
const req = request(f.base + '/api/project-remotes/books', res => { let text = ''; res.on('data', chunk => { text += chunk; }); res.on('end', () => resolve([res.statusCode, JSON.parse(text)])); });
req.on('error', reject); req.end();
});
assert.deepEqual(library, [403, { error: 'project-remote-library' }]);
const [, before] = await get(f), document = JSON.parse(readFileSync(f.file));
document.projects.stack.root += '-other'; writeFileSync(f.file, JSON.stringify(document));
const bytes = readFileSync(f.file), [status, result] = await post(f, { revision: before.revision, remote: url });
assert.equal(status, 409); assert.equal(result.error, 'project-remote-protected'); assert.deepEqual(readFileSync(f.file), bytes);
});
+15 -1
View File
@@ -147,7 +147,7 @@ test('request panel: only the latest three owner messages get a panel, and a req
const { b, w, count } = await setup(t, { boot: { pages: [{ rows, after: rows.at(-1).id, hasMore: false }], states } });
await w('document.querySelectorAll(".request-panel").length === 3');
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".request-mount")].map(e => e.dataset.request)'), ids.slice(2));
assert.equal(await b.evaluate('window.__rreads.filter(p => /^\\/api\\/requests\\/[^/]+$/.test(p)).length'), 3);
assert.deepEqual(await b.evaluate('[...new Set(window.__rreads.filter(p => /^\\/api\\/requests\\/[^/]+$/.test(p)).map(p => p.split("/").at(-1)))].sort()'), ids.slice(2).sort(), 'the panel, Files, and summary only read the three latest request IDs');
// The third newest is unknown to the bus: no panel, no error banner.
const gone = await setup(t, { boot: { pages: [{ rows: [ROW], after: M, hasMore: false }], states: {} } });
await gone.w(`window.__rreads.some(p => p === "/api/requests/${R}")`);
@@ -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(), []);
});
+14 -11
View File
@@ -1,5 +1,5 @@
// #1542 (row 53): the Console shell restyle. Static checks: the design copies match
// docs/design byte for byte, the mono fonts match their recorded digests, and the shipped
// icons match docs/design, shared tokens remain available, mono fonts match their digests, and the shipped
// strings are unchanged. Browser checks: System is the default appearance, the board and
// bus states (empty, a failed read over kept data, a refusal), no sideways scroll at 400px,
// visible keyboard focus, the Ctrl+K palette, and each view in its five states. Nothing here
@@ -18,8 +18,10 @@ import { BusReadError } from '../src/bus.mjs';
const pub = new URL('../src/public/', import.meta.url), design = new URL('../../../docs/design/', import.meta.url);
const read = (name, base = pub) => readFileSync(new URL(name, base));
test('tokens.css and icons.svg are byte copies of docs/design', () => {
for (const name of ['tokens.css', 'icons.svg']) assert.ok(read(name).equals(read(name, design)), `${name} differs from docs/design/${name}; copy it again`);
test('icons retain the design source and W15 geometry tokens are present', () => {
assert.ok(read('icons.svg').equals(read('icons.svg', design)));
const css = read('tokens.css').toString();
for (const token of ['--rail-width: 88px', '--sidebar-width: 226px', '--action-target: 44px']) assert.ok(css.includes(token), token);
});
test('JetBrains Mono files match the digests in their sources file, with the OFL text', () => {
@@ -135,7 +137,7 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct
await b.call('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: 'light' }] });
assert.ok(await b.evaluate('(()=>{const [r,g,bb]=getComputedStyle(document.body).backgroundColor.match(/[\\d.]+/g).map(Number);return (r+g+bb)/3})()') > 180, 'light OS gives a light page');
assert.equal(await b.evaluate('getComputedStyle(document.documentElement).getPropertyValue("--r").trim()'), '6px');
assert.equal(await b.evaluate('getComputedStyle(document.documentElement).getPropertyValue("--r-lg").trim()'), '10px');
assert.equal(await b.evaluate('getComputedStyle(document.documentElement).getPropertyValue("--r-lg").trim()'), '6px');
assert.equal(await b.evaluate('document.fonts.check("12px \\"JetBrains Mono\\"")'), true);
await b.evaluate('document.querySelector("#mode").value="dark";document.querySelector("#mode").dispatchEvent(new Event("change"))');
assert.equal(await b.evaluate('document.documentElement.dataset.mode'), 'dark');
@@ -273,17 +275,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 +494,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/);
+1 -1
View File
@@ -56,7 +56,7 @@ async function page(b, base) {
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: PROBE });
await b.viewport(400, 900);
await b.navigate(base);
await wait(`window.__stubReady === true && document.querySelectorAll("#sections a").length===10 && document.querySelector('#sections a[href="#/chat"]')?.textContent.trim()==='Chat'`);
await wait(`window.__stubReady === true && document.querySelectorAll("#sections a").length===10 && document.querySelector('#sections a[href="#/projects"]')?.textContent.trim()==='Projects'`);
return { wait, text, count, flat, refresh, go };
}
@@ -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/);
}
});
@@ -0,0 +1,54 @@
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 presents reviewed delivery and every names-only draft state at both widths', 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' } } };
const base = { available: true, entries: [], truncated: false, contentAvailable: false, observedAt: '2026-11-05T00:00:00.000Z' };
const scenarios = [
['draft-list', { ...base, entries: [{ name: 'src/timer.js' }, { name: 'README.md' }] }, /Names only.*src\/timer.js.*README.md/s],
['draft-empty', base, /No draft files found/],
['draft-unavailable', { ...base, available: false }, /Draft workspace is unavailable/],
['draft-truncated', { ...base, entries: [{ name: 'src/timer.js' }], truncated: true }, /src\/timer.js.*truncated/s],
['draft-error', null, /Could not read draft workspace/],
];
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);
})()`);
for (const [name, workspace, expected] of scenarios) {
await b.evaluate(`(async () => { window.fixtureWorkspace = ${JSON.stringify(workspace)}; await MosaicFiles.mount({container: document.querySelector('#slice-fixture'), requestIds:['request-1']}); })()`);
const text = await b.evaluate("document.querySelector('#slice-fixture').textContent");
assert.match(text, /Reviewed delivery.*timer.html.*Open as text.*Download.*Product workspace, draft/s);
assert.match(text, expected);
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);
await b.evaluate("document.querySelector('#slice-fixture a[download]').focus()");
await b.key('Tab');
assert.equal(await b.evaluate("document.activeElement.getAttribute('aria-label')"), 'Product workspace, draft');
await b.key('Tab', 'Tab', 8);
assert.equal(await b.evaluate("document.activeElement.hasAttribute('download')"), true);
await b.call('Input.dispatchMouseEvent', { type: 'mousePressed', x: 2, y: 2, button: 'left', clickCount: 1 });
await b.call('Input.dispatchMouseEvent', { type: 'mouseReleased', x: 2, y: 2, button: 'left', clickCount: 1 });
if (shots) {
await b.screenshot(join(shots, `files-${name}-${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-${name}-${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, []);
});
+64
View File
@@ -0,0 +1,64 @@
import { mkdir, writeFile } 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, { scenario = {}, slice = 'shell' } = {}) {
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');
b.on('Fetch.requestPaused', ({ requestId, request }) => {
const url = new URL(request.url);
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=${JSON.stringify(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});
const key = method+' '+path;
if (Object.hasOwn(__scenario,key)) { const response=__scenario[key]; return data(response.body ?? {},response.status ?? 200); }
if(method!=='GET') { __unexpected.push(key); return data({error:'fixture-write-refused'},403); }
if (Object.hasOwn(__scenario,path)) return data(__scenario[path]);
if(/^\\/api\\/library\\/status\\?project=[^&]+$/.test(path) || 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:true,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:[{instance:'assistant',name:'Assistant'}]});
if(/^\\/api\\/chat\\/threads\\/[^/]+\\/shares$/.test(path)) 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 = `${slice}-${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 };
}
@@ -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:'<img src=x onerror=alert(1)>\\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 <img src=x>\\u202e'},routeTo:'human'}); return {text:host.textContent, images:host.querySelectorAll('img').length}; })()`);
assert.match(result.text, /Affected object: workspace <img src=x>\[U\+202E\].*Decision routed to: human.*Destination: web1.*unknown/s);
assert.equal(result.images, 0);
});
@@ -0,0 +1,28 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { w15Fixture, ids } from './w15-fixture.mjs';
test('integrated project settings and conversation mounts use authorized context', { timeout: 90000 }, async t => {
const scenario = {
'/api/config': { send: true, requests: true, projectRemotes: true, projectRemoteWrites: true },
'/api/project-remotes/fixture-repository': { project: ids.repository, remote: null, revision: 'a'.repeat(64), firstSaveAllowed: true },
};
const { f, b, wait } = await w15Fixture(t, { scenario });
await b.viewport(390, 1000);
await b.navigate(f.base + `/#/projects/${ids.project}/settings`);
await wait('!!document.querySelector("#project-remote-url")');
assert.equal(await b.evaluate('document.querySelector("#project-context .context-project")?.getAttribute("href")'), `#/projects/${ids.project}`);
assert.equal(await b.evaluate('[...document.querySelectorAll("link")].some(x => x.getAttribute("href") === "/requests.css") && typeof MosaicFiles.mount === "function"'), true);
const route = `#/projects/threads/${ids.thread}/sessions/${ids.session}`;
await b.evaluate(`location.hash=${JSON.stringify(route)}`);
await wait(`document.querySelector('.chat-conversation')?.dataset.session===${JSON.stringify(ids.session)}`);
await wait('!!document.querySelector("[data-chat-files] .request-files") || document.querySelector("[data-chat-files]")?.textContent.includes("No")');
assert.match(await b.evaluate('document.querySelector("#project-context").textContent'), /Active project.*Project conversation/s);
assert.equal(await b.evaluate('document.querySelector("#project-context .context-conversation").getAttribute("href")'), route);
await b.viewport(1440, 1000);
await wait('document.querySelector("#processing-controls").parentElement.id === "console-nav"');
assert.equal(await b.evaluate('document.querySelector("#processing-controls").parentElement.id'), 'console-nav');
assert.deepEqual(await b.evaluate('window.__errors'), []);
assert.deepEqual(await b.evaluate('window.__writes'), []);
assert.deepEqual(await b.evaluate('window.__unexpected'), []);
});
@@ -0,0 +1,116 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { remoteFixture } from './helpers/w15-project-remote.mjs';
for (const width of [1440, 390]) test(`project remote fixture at ${width}px`, async t => {
const { b, mount, wait, errors, network } = await remoteFixture(t, width);
const shots = process.env.MOSAIC_UX_SHOTS;
if (shots) mkdirSync(shots, { recursive: true });
async function shot(name) {
if (!shots) return;
await b.screenshot(join(shots, `project-remote-${name}-${width}.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: contentSize.width, height: contentSize.height, scale: 1 } });
const { writeFile } = await import('node:fs/promises');
await writeFile(join(shots, `project-remote-${name}-${width}-full.png`), Buffer.from(data, 'base64'));
}
async function input(value) {
await b.evaluate(`(()=>{const e=document.querySelector('#project-remote-url');e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('input',{bubbles:true}));return true})()`);
}
async function submit() {
const rect = await b.evaluate(`(()=>{const r=document.querySelector('.project-remote button[type=submit]').getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()`);
await b.call('Input.dispatchMouseEvent', { type: 'mousePressed', button: 'left', clickCount: 1, ...rect });
await b.call('Input.dispatchMouseEvent', { type: 'mouseReleased', button: 'left', clickCount: 1, ...rect });
}
await mount(); await wait(`document.querySelector('#project-remote-url')`);
for (const mode of ['light', 'dark']) {
await b.evaluate(`(()=>{const e=document.querySelector('#mode');e.value='${mode}';e.dispatchEvent(new Event('change',{bubbles:true}));return true})()`);
assert.equal(await b.evaluate(`document.documentElement.dataset.mode`), mode);
assert.equal(await b.evaluate(`getComputedStyle(document.querySelector('#project-remote-url')).visibility`), 'visible');
}
await b.evaluate(`(()=>{const e=document.querySelector('#mode');e.value='system';e.dispatchEvent(new Event('change',{bubbles:true}));return true})()`);
const ax = await b.call('Accessibility.getFullAXTree');
assert.ok(ax.nodes.some(n => n.role?.value === 'textbox' && n.name?.value === 'Existing HTTPS repository URL'));
assert.ok(ax.nodes.some(n => n.role?.value === 'button' && n.name?.value === 'Save'));
assert.equal(await b.evaluate(`document.querySelector('.project-remote button[type=submit]').getBoundingClientRect().height >= 44`), true);
await b.evaluate(`document.querySelector('#project-remote-url').focus(),true`);
await b.key('Tab'); assert.equal(await b.evaluate(`document.activeElement.textContent`), 'Save');
await b.key('Tab', 'Tab', 8); assert.equal(await b.evaluate(`document.activeElement.id`), 'project-remote-url');
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), true);
// Halving the CSS viewport models the reflow available at 200% browser zoom.
await b.viewport(width / 2, 500);
assert.equal(await b.evaluate(`document.querySelector('.project-remote').scrollWidth <= document.querySelector('.project-remote').clientWidth`), true);
await b.viewport(width, 1000);
await shot('remote-missing');
await b.key('Tab'); await b.key('Tab', 'Tab', 8); await b.key('Escape');
await input('https://git.example/owner/repo.git'); await shot('remote-editing');
await b.evaluate(`window.__mode='hold',true`); await submit();
await wait(`window.__writes.length===1 && !!window.__release`);
assert.equal(await b.evaluate(`document.querySelector('.project-remote button[type=submit]').disabled`), true);
await b.evaluate(`document.querySelector('.project-remote form').dispatchEvent(new Event('submit',{bubbles:true,cancelable:true})),window.__release(),true`);
await wait(`document.querySelector('.project-remote').textContent.includes('Configured, not verified')`);
assert.equal(await b.evaluate('window.__writes.length'), 1); await shot('remote-saved-not-published');
await b.evaluate(`window.__remote={project:'stack',remote:null,revision:'${'a'.repeat(64)}',firstSaveAllowed:true};window.__mode='stale';true`);
await mount(); await wait(`document.querySelector('#project-remote-url')`); await input('https://git.example/owner/repo.git'); await submit();
await wait(`document.querySelector('.project-remote').textContent.includes('setting changed')`); await shot('remote-stale');
assert.equal(await b.evaluate(`document.querySelector('#project-remote-url').value`), 'https://git.example/owner/repo.git');
await b.evaluate(`document.querySelector('.project-remote button[type=button]').click(),true`);
await wait(`document.querySelector('.project-remote').textContent.includes('Review your entry')`);
assert.equal(await b.evaluate(`document.querySelector('#project-remote-url').value`), 'https://git.example/owner/repo.git');
await input('https://name:[email protected]/owner/repo'); await submit();
await wait(`document.querySelector('.project-remote').textContent.includes('must not contain credentials')`);
assert.equal(await b.evaluate(`document.querySelector('#project-remote-url').value`), ''); await shot('remote-invalid');
await b.evaluate(`window.__mode='fail',true`); await input('https://git.example/owner/invalid'); await submit();
await wait(`document.querySelector('.project-remote').textContent.includes('Enter a valid HTTPS')`);
assert.equal(await b.evaluate(`document.querySelector('#project-remote-url').value`), 'https://git.example/owner/invalid');
await b.evaluate(`window.__mode='unknown',true`); await input('https://git.example/owner/unknown'); await submit();
await wait(`document.querySelector('.project-remote').textContent.includes('Save outcome is unknown')`);
assert.equal(await b.evaluate(`document.querySelector('.project-remote button[type=submit]').disabled`), true);
assert.equal(await b.evaluate(`document.querySelector('#project-remote-url').value`), 'https://git.example/owner/unknown');
await b.evaluate(`window.__writeEnabled=false,true`); await mount();
await wait(`document.querySelector('.project-remote').textContent.includes('read-only')`);
assert.equal(await b.evaluate(`document.querySelectorAll('.project-remote form').length`), 0);
await mount(null); await shot('remote-readonly-or-unlinked');
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), true);
await b.evaluate(`document.dispatchEvent(new Event('mosaic-signed-out')),true`);
assert.equal(await b.evaluate(`document.querySelector('.project-remote').textContent`), '');
assert.deepEqual(network, []); assert.deepEqual(errors, []);
});
for (const width of [1440, 390]) test(`real fixture saves the project remote at ${width}px`, async t => {
const { realRemoteFixture } = await import('./helpers/w15-project-remote-real.mjs');
const { browser } = await import('./browser.mjs');
const { readFileSync } = await import('node:fs');
const f = await realRemoteFixture(t), b = await browser({ machine: false });
t.after(() => b.close());
const errors = [], blocked = [];
b.on('Runtime.exceptionThrown', p => errors.push(p.exceptionDetails?.text));
b.on('Fetch.requestPaused', p => {
if (p.request.url.startsWith(f.base + '/')) b.call('Fetch.continueRequest', { requestId: p.requestId }).catch(() => {});
else { blocked.push(p.request.url); b.call('Fetch.failRequest', { requestId: p.requestId, errorReason: 'BlockedByClient' }).catch(() => {}); }
});
await b.call('Fetch.enable', { patterns: [{ urlPattern: '*' }] });
await b.viewport(width, 1000); await b.navigate(f.base + '/#/projects/10000000-0000-4000-8000-000000000001/settings');
await b.evaluate(`new Promise((resolve,reject)=>{const link=document.createElement('link');link.rel='stylesheet';link.href='/project-remote.css';document.head.append(link);const script=document.createElement('script');script.src='/project-remote.js';script.onload=resolve;script.onerror=reject;document.head.append(script)})`);
await b.evaluate(`(()=>{const main=document.querySelector('#main');for(const child of main.children)child.style.display='none';main.hidden=false;const h=document.createElement('h1');h.textContent='Project information';const slot=document.createElement('div');main.append(h,slot);MosaicProjectRemote.mount({container:slot,projectId:'10000000-0000-4000-8000-000000000001',repositoryKey:'stack'});return true})()`);
const wait = async expression => b.evaluate(`(async()=>{const until=Date.now()+10000;while(!(${expression})){if(Date.now()>until)throw Error('timed out');await new Promise(r=>setTimeout(r,20))}return true})()`);
await wait(`document.querySelector('#project-remote-url')`);
await b.evaluate(`(()=>{const e=document.querySelector('#project-remote-url');e.value='https://git.example/owner/repo.git';e.dispatchEvent(new Event('input',{bubbles:true}));return true})()`);
const rect = await b.evaluate(`(()=>{const r=document.querySelector('.project-remote button[type=submit]').getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()`);
await b.call('Input.dispatchMouseEvent', { type: 'mousePressed', button: 'left', clickCount: 1, ...rect });
await b.call('Input.dispatchMouseEvent', { type: 'mouseReleased', button: 'left', clickCount: 1, ...rect });
await wait(`document.querySelector('.project-remote').textContent.includes('Configured, not verified')`);
assert.equal(JSON.parse(readFileSync(f.file)).projects.stack.remote, 'https://git.example/owner/repo.git');
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), true);
if (process.env.MOSAIC_UX_SHOTS) {
mkdirSync(process.env.MOSAIC_UX_SHOTS, { recursive: true });
await b.screenshot(join(process.env.MOSAIC_UX_SHOTS, `project-remote-real-saved-${width}.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: contentSize.width, height: contentSize.height, scale: 1 } });
const { writeFile } = await import('node:fs/promises');
await writeFile(join(process.env.MOSAIC_UX_SHOTS, `project-remote-real-saved-${width}-full.png`), Buffer.from(data, 'base64'));
}
assert.deepEqual(blocked, []); assert.deepEqual(errors, []);
});
@@ -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);
});
@@ -0,0 +1,183 @@
import test, { after } 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';
const captures = [];
after(async () => { if (process.env.MOSAIC_UX_SHOTS) await writeFile(join(process.env.MOSAIC_UX_SHOTS,'manifest.json'),JSON.stringify({candidate:'uncommitted; see report',command:'node --test --test-concurrency=1 packages/webui/tests/w15-shell-browser.test.mjs',result:'see saved test output',captures:captures.filter(Boolean)},null,2)+'\n'); });
test('shell fixture: project navigation and narrow drawer', { timeout: 90000 }, async t => {
const { f,b,wait,capture } = await w15Fixture(t, {scenario:{'/api/processing':{version:1,generation:'12345678-1234-4234-8234-123456789abc',halted:false,quiescent:true,remaining:0,stopped:0}}});
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"');
await wait('!document.querySelector("#processing-halt").disabled');
assert.equal(await b.evaluate(`(() => { const s=document.querySelector('#processing-halt'), r=s.getBoundingClientRect(), bar=document.querySelector('.cmdbar').getBoundingClientRect(); s.focus(); return document.querySelectorAll('#processing-halt').length===1 && !document.body.classList.contains('nav-open') && r.top>=bar.bottom && r.bottom<=innerHeight && getComputedStyle(s).visibility==='visible' && getComputedStyle(s).order==='0' && document.activeElement===s; })()`), true);
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);
if (width === 1440) assert.deepEqual(await b.evaluate('[Math.round(document.querySelector(".rail").getBoundingClientRect().width),Math.round(document.querySelector(".tree").getBoundingClientRect().x),Math.round(document.querySelector(".tree").getBoundingClientRect().width),Math.round(document.querySelector("#main").getBoundingClientRect().x)]'), [88,88,226,314]);
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);
assert.equal(await b.evaluate('document.querySelector("#processing-mobile").inert'), true);
assert.equal(await b.evaluate('document.querySelector("#sections [aria-current]").getBoundingClientRect().top < innerHeight'), 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);
const point = await b.evaluate('(()=>{const r=document.querySelector("#nav-open").getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()');
await b.call('Input.dispatchMouseEvent',{type:'mousePressed',...point,button:'left',clickCount:1});
await b.call('Input.dispatchMouseEvent',{type:'mouseReleased',...point,button:'left',clickCount:1});
assert.equal(await b.evaluate('document.activeElement.id'), 'nav-close');
await b.key('Enter'); assert.equal(await b.evaluate('document.activeElement.id'), 'nav-open');
} 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'), []);
assert.deepEqual(await b.evaluate('window.__unexpected'), []);
}
await b.viewport(400,1000); await b.navigate(f.base+'/#/projects');
await wait('document.querySelector(".chat-area h1")?.textContent==="Projects" && !document.querySelector("#processing-halt").disabled');
captures.push(await capture('projects-list',400,'#/projects'));
});
test('display module on the real fixture server: save, failure and destination', {timeout:90000}, async t => {
const { f,b,wait } = await w15Fixture(t);
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.match(await b.evaluate('document.querySelector("#display-announcement").textContent'), /Sidebar display saved/);
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'), []);
});
test('canonical routes survive reload and history without writes', { timeout: 90000 }, async t => {
const { f,b,wait } = await w15Fixture(t);
const paths = [
['#/projects', '.chat-projects'],
[`#/projects/${ids.project}`, '.chat-area h1'],
[`#/projects/threads/${ids.thread}/sessions/${ids.session}`, `.chat-conversation[data-session="${ids.session}"]`],
[`#/projects/rooms/${ids.project}`, '.chat-room'],
];
for (const [route,selector] of paths) {
await b.navigate(f.base + '/' + route);
await wait(`!!document.querySelector(${JSON.stringify(selector)})`);
await b.evaluate('window.__beforeReload=true');
await b.call('Page.reload');
await wait(`!window.__beforeReload && document.readyState==='complete' && !!document.querySelector(${JSON.stringify(selector)})`);
assert.equal(await b.evaluate('location.hash'), route);
try { await wait('document.activeElement.tagName !== "BODY"'); } catch (e) { throw new Error(route + ': ' + e.message); }
await b.evaluate('location.hash="#/tasks"');
await wait('location.hash==="#/tasks"');
await b.evaluate('history.back()');
await wait(`location.hash===${JSON.stringify(route)} && !!document.querySelector(${JSON.stringify(selector)})`);
await b.evaluate('history.forward()');
await wait('location.hash==="#/tasks"');
}
assert.deepEqual(await b.evaluate('window.__writes'), []);
});
test('drawer preserves conversation draft, displays known context and contains focus', { timeout: 90000 }, async t => {
const { f,b,wait,capture } = await w15Fixture(t);
for (const width of [1440,390]) {
await b.viewport(width,1000);
const route = `#/projects/threads/${ids.thread}/sessions/${ids.session}`;
await b.navigate(f.base + '/' + route);
await wait('!!document.querySelector("#chat-body") && !!document.querySelector("#project-context .context-conversation")');
await b.evaluate('document.querySelector("#chat-body").value="Unsent fixture draft";document.querySelector("#chat-body").dispatchEvent(new Event("input",{bubbles:true}))');
assert.match(await b.evaluate('document.querySelector("#project-context").textContent'), /Active project.*Project conversation/s);
if (width===390) {
await b.evaluate('document.querySelector("#nav-open").click()');
const ax = await b.call('Accessibility.getFullAXTree');
assert.ok(ax.nodes.some(n => n.role?.value==='navigation' && n.name?.value==='Console'));
assert.ok(ax.nodes.some(n => n.name?.value==='Current project and conversation'));
await b.key('Tab','Tab',8);
assert.equal(await b.evaluate('document.activeElement.closest("#console-nav") !== null'), true);
await b.key('Escape');
assert.equal(await b.evaluate('document.activeElement.id'), 'nav-open');
}
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'Unsent fixture draft');
captures.push(await capture('navigation-closed-with-draft',width,route));
await b.evaluate('document.querySelector("#refresh").focus();document.querySelector("#refresh").click()');
await wait('document.querySelector("#chat-body")?.value==="Unsent fixture draft"');
assert.notEqual(await b.evaluate('document.activeElement.tagName'), 'BODY');
}
assert.deepEqual(await b.evaluate('window.__writes'), []);
assert.deepEqual(await b.evaluate('window.__unexpected'), []);
});
test('display screenshots and zoom, dark mode, hostile labels', { timeout: 90000 }, async t => {
const hostile = '<img src=x onerror=alert(1)> ' + 'Long project '.repeat(35) + '\u202eevil';
const { f,b,wait,capture } = await w15Fixture(t, {scenario:{'/api/chat/projects':{rows:[{id:ids.project,name:hostile,repo:ids.repository}]}}});
for (const width of [1440,390]) {
await b.viewport(width,1000); await b.navigate(f.base+'/#/settings/display');
await wait('!!document.querySelector("#display-form")');
assert.ok(await b.evaluate('document.querySelector("#display-form button[type=submit]").getBoundingClientRect().height >= 44'));
await b.evaluate('document.documentElement.style.zoom="";document.documentElement.removeAttribute("data-mode")');
await b.evaluate('document.querySelector("[name=density][value=compact]").click();document.querySelector("#display-form").requestSubmit()');
await wait('document.querySelector("#display-status").textContent.includes("saved")');
captures.push(await capture('display-saved',width,'#/settings/display'));
await b.evaluate('document.querySelector("[name=order][value=contact-first]").click();location.hash="#/projects"');
await wait('document.querySelector(".display-dialog").open');
assert.equal(await b.evaluate('document.querySelector("#display-leave-status").textContent'), '');
captures.push(await capture('display-unsaved',width,'#/settings/display'));
await b.evaluate('window.__setItem=Storage.prototype.setItem;Storage.prototype.setItem=function(){throw new Error("blocked")};document.querySelector("[data-choice=save]").click()');
await wait('document.querySelector("#display-leave-status").textContent.includes("local browser storage")');
captures.push(await capture('display-save-failed',width,'#/settings/display'));
await b.evaluate('Storage.prototype.setItem=window.__setItem;document.querySelector("[data-choice=discard]").click()');
await wait('location.hash==="#/projects" && !!document.querySelector(".chat-projects")');
assert.equal(await b.evaluate('!!document.querySelector(".chat-projects img")'),false);
await b.evaluate('document.documentElement.dataset.mode="dark"; document.documentElement.style.zoom="2"');
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'),true);
assert.equal(await b.evaluate('getComputedStyle(document.documentElement).colorScheme'), 'dark');
}
});
test('optional project tree mount, storage refusal and sign-out clear transient choices', {timeout:90000}, async t => {
const { f,b,wait } = await w15Fixture(t);
await b.call('Page.addScriptToEvaluateOnNewDocument', {source:`window.MosaicProjectTree={mount({container,path}){container.textContent='Stub tree: '+path}};`});
await b.navigate(f.base+'/#/settings/display');
await wait('!!document.querySelector("#display-form")');
assert.match(await b.evaluate('document.querySelector("#project-tree").textContent'), /Stub tree: \/settings\/display/);
await b.evaluate('document.querySelector("[name=order][value=contact-first]").click(); document.dispatchEvent(new Event("mosaic-signed-out")); location.hash="#/projects"');
await wait('location.hash==="#/projects"');
assert.equal(await b.evaluate('document.querySelector(".display-dialog").open'), false);
assert.equal(await b.evaluate('localStorage.getItem("mosaic-sidebar-display-v1")'), null);
assert.deepEqual(await b.evaluate('window.__writes'), []);
});
test('owner display asset is not exposed through the limited user route', async () => {
const {userRoute} = await import('../src/serve.mjs');
assert.equal(userRoute('/display.js','GET'), false);
assert.equal(userRoute('/display.js','HEAD'), false);
});
test('shared fixture refuses unknown APIs and permits only explicit scenario writes', {timeout:90000}, async t => {
const { f,b,wait } = await w15Fixture(t, {scenario:{'POST /api/fixture/allowed':{status:202,body:{accepted:true}}}});
await b.navigate(f.base+'/#/projects'); await wait('!!document.querySelector(".chat-projects")');
const result = await b.evaluate(`(async()=>{const a=await fetch('/api/unknown'),b=await fetch('/api/fixture/unknown',{method:'POST'}),c=await fetch('/api/fixture/allowed',{method:'POST'}),d=await fetch('https://example.invalid/');return [a.status,b.status,c.status,d.status]})()`);
assert.deepEqual(result,[403,403,202,403]);
assert.deepEqual(await b.evaluate('window.__writes.map(x=>x.path)'), ['/api/fixture/unknown','/api/fixture/allowed']);
assert.ok((await b.evaluate('window.__unexpected')).includes('POST /api/fixture/unknown'));
});
@@ -0,0 +1,29 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { requestHTTP, REQUEST_ROUTE } from '../src/request-http.mjs';
const session = { name: 'owner', provider: 'test', iss: 'test', sub: 'owner' };
const reply = { available: true, entries: [{ name: 'src/app.js' }], truncated: false, contentAvailable: false, observedAt: '2026-11-05T00:00:00.000Z' };
async function attempt({ path = '/api/requests/r1/workspace', url = path, method = 'GET', read = async () => reply, live = () => session, headersDistinct = {} } = {}) {
const req = { url, method, headersDistinct }, res = { writeHead(status, headers) { this.status = status; this.headers = headers; }, end(data) { this.body = data; } };
await requestHTTP(req, res, { path, session, live, requestRead: read, origin: 'https://console.test', log() {} }); return res;
}
test('owner workspace GET sends only the request identifier and validates result', async () => {
let call;
const res = await attempt({ read: async (...args) => { call = args; return reply; } });
assert.equal(res.status, 200); assert.deepEqual(call[0], 'request.workspace.console'); assert.deepEqual(call[1], { request: 'r1' });
assert.equal(res.headers['cache-control'], 'no-store');
assert.equal((await attempt({ read: async () => ({ ...reply, entries: [{ name: '../secret' }] }) })).status, 503);
assert.equal((await attempt({ read: async () => ({ ...reply, entries: Array(101).fill({ name: 'x' }) }) })).status, 503);
assert.equal((await attempt({ read: async () => ({ ...reply, path: '/etc' }) })).status, 503);
});
test('workspace route refuses traversal, other modes, query, foreign origin and stale session', async () => {
assert.equal((await attempt({ path: '/api/requests/%2e%2e/workspace' })).status, 404);
assert.equal((await attempt({ url: '/api/requests/r1/workspace?path=..' })).status, 400);
assert.equal((await attempt({ method: 'POST' })).status, 405);
assert.equal((await attempt({ headersDistinct: { origin: ['https://evil.test'] } })).status, 403);
let reads = 0;
assert.equal((await attempt({ read: async () => { reads++; return reply; }, live: () => reads ? null : session })).status, 401);
assert.doesNotMatch('/api/requests/r1/workspace/a/text', REQUEST_ROUTE);
assert.doesNotMatch('/api/requests/r1/workspace/a/download', REQUEST_ROUTE);
});