feat(webui): list verified draft workspace names (#797)

This commit is contained in:
jason.woltje committed 2026-11-05 21:52:26 -06:00
1 parent 59801efe4d
commit 551acdf3d3
15 files changed
+358 -31

No files matched your search

+19 -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(['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;
@@ -5425,6 +5427,21 @@ 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 (['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(); }
}
+2
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';
@@ -585,6 +586,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) => {
+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], ['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);
+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], ['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);
});
+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);
});
+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. consoleCall refuses any verb that is not a console verb; workspace reads use this owner 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'}`;
+1
View File
@@ -19,6 +19,7 @@ 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 } = {}) {
const o = {
proxyCall: agentCaller(call, PROXY_VERBS),
+18 -11
View File
@@ -11,15 +11,15 @@
if (className) node.className = className;
return node;
}
async function read(url) {
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() : null;
} catch { 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);
@@ -37,7 +37,7 @@
const ids = Array.isArray(requestIds) ? [...new Set(requestIds.filter(id))] : [];
const results = await Promise.all(ids.map(async request => {
const base = `/api/requests/${encodeURIComponent(request)}`;
const [state, workspace] = await Promise.all([read(base), read(`${base}/workspace`)]);
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;
@@ -56,20 +56,27 @@
} else section.append(element('p', 'Reviewed file is unavailable.'));
} else section.append(element('p', state ? 'No reviewed file is available.' : 'Reviewed files are unavailable.'));
section.append(element('p', 'No request-bound pull request link is available.'));
section.append(element('h5', 'Product workspace, draft'));
if (!workspace || workspace.available !== true || !Array.isArray(workspace.entries)) {
section.append(element('p', 'Draft workspace is unavailable.'));
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 {
section.append(element('p', 'Names only. Draft files may change and are not reviewed deliveries.'));
if (!workspace.entries.length) section.append(element('p', 'No draft files found.'));
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));
}
section.append(list);
draft.append(list);
}
if (workspace.truncated) section.append(element('p', 'The draft list was truncated.'));
if (workspace.truncated) draft.append(element('p', 'The draft list was truncated.'));
}
container.append(section);
}
+13 -2
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)\/([^/]+)(\/(?:text))?)?$/;
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({
@@ -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 {}
@@ -196,9 +197,19 @@ export async function requestHTTP(req, res, { path, requestRead, requestWrite, s
}
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('');
+1 -1
View File
@@ -929,7 +929,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 } : {}), ...(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);
+30 -11
View File
@@ -4,10 +4,18 @@ import { join } from 'node:path';
import { writeFile } from 'node:fs/promises';
import { filesFixture } from './helpers/w15-files.mjs';
test('Files mount reads request identity and presents safe reviewed links and unavailable draft', async t => {
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 + '/#/');
@@ -16,17 +24,28 @@ test('Files mount reads request identity and presents safe reviewed links and un
await new Promise((resolve, reject) => { const s = document.createElement('script'); s.src = '/files.js'; s.onload = resolve; s.onerror = reject; document.head.append(s); });
window.fixtureState = ${JSON.stringify(state)};
const main = document.querySelector('#main'); main.replaceChildren(); const mount = document.createElement('div'); mount.id = 'slice-fixture'; main.append(mount);
await window.MosaicFiles.mount({container: mount, requestIds: ['request-1']});
})()`);
assert.match(await b.evaluate("document.querySelector('#slice-fixture').textContent"), /Reviewed delivery.*timer.html.*Open as text.*Download.*Product workspace, draft.*unavailable/s);
assert.equal(await b.evaluate("document.querySelector('#slice-fixture a').getAttribute('href')"), '/api/requests/request-1/artifact/delivery-1/text');
assert.equal(await b.evaluate("document.querySelector('#slice-fixture a').rel"), 'noopener noreferrer');
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), true);
if (shots) {
await b.screenshot(join(shots, `files-isolated-unavailable-${width}.png`));
const { cssContentSize } = await b.call('Page.getLayoutMetrics');
const { data } = await b.call('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true, clip: { x: 0, y: 0, width: cssContentSize.width, height: cssContentSize.height, scale: 1 } });
await writeFile(join(shots, `files-isolated-unavailable-${width}-full.png`), Buffer.from(data, 'base64'));
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']}); })()`);
@@ -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);
});