feat(webui): list verified draft workspace names (#797)
This commit is contained in:
1 parent
59801efe4d
commit
551acdf3d3
15 files changed
+358
-31
No files matched your search
@@ -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');
|
||||
|
||||
@@ -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(); }
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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'}`;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user