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); });