import test from 'node:test'; import assert from 'node:assert/strict'; import { connect } from 'node:net'; import { join } from 'node:path'; import { fixture, git } from './spec-fixtures.mjs'; import { serve } from '../src/server.mjs'; import { Client } from '../src/client.mjs'; import { refusalGuidance } from '../src/refusals.mjs'; import { PROJECT_FILE_VERBS } from '../src/broker.mjs'; // Row 148 (#1642): the human-only verbs behind the Console's spec and docs editor. const refusals = f => f.store.all("SELECT body FROM events WHERE kind='action.refused'").map(r => JSON.parse(r.body).code); const state = f => ({ head: git(f.repo, 'rev-parse', 'HEAD'), status: git(f.repo, 'status', '--porcelain') }); const reject = (promise, code) => assert.rejects(Promise.resolve(promise), { code }); test('a human lists, opens and saves; the save is one commit by the business human', async t => { const f = await fixture(t); const listed = await f.call(f.human, 'project.files', { project: 'stack' }); assert.deepEqual(listed.files.map(x => x.path), ['docs/README.md', 'spec/north-star.md']); const open = await f.call(f.human, 'project.file', { project: 'stack', path: 'spec/north-star.md' }); assert.deepEqual(Object.keys(open).sort(), ['at', 'author', 'commit', 'content', 'head', 'path']); assert.equal(open.commit, f.commit); assert.equal(open.head, f.commit); assert.equal(open.author, 'jason'); assert.equal(JSON.stringify(open).includes(f.repo), false); const saved = await f.call(f.human, 'project.file.save', { project: 'stack', path: 'spec/north-star.md', base: open.commit, content: '# Edited\n\nBy hand.\n' }); assert.deepEqual(Object.keys(saved).sort(), ['commit', 'path']); assert.equal(git(f.repo, 'rev-parse', 'HEAD'), saved.commit); assert.equal(git(f.repo, 'rev-list', '--count', `${f.commit}..HEAD`), '1'); assert.equal(git(f.repo, 'show', '-s', '--format=%an|%s', saved.commit), 'jason|Edit spec/north-star.md'); const again = await f.call(f.human, 'project.file', { project: 'stack', path: 'spec/north-star.md' }); assert.equal(again.content, '# Edited\n\nBy hand.\n'); assert.equal(again.commit, saved.commit); assert.equal(git(f.repo, 'status', '--porcelain'), ''); }); test('an agent and a reader refuse every file verb and nothing is written', async t => { const f = await fixture(t); const reader = f.broker.bindReader({ business: 'demo' }); const before = state(f), args = { project: 'stack', path: 'spec/north-star.md', base: f.commit, content: '# Agent\n' }; for (const [cap, code] of [[f.coder, 'human-required'], [f.reviewer, 'human-required'], [reader, 'read-only']]) for (const verb of PROJECT_FILE_VERBS) await reject(f.call(cap, verb, verb === 'project.file.save' ? args : verb === 'project.file' ? { project: 'stack', path: args.path } : { project: 'stack' }), code); assert.deepEqual(state(f), before); assert.equal(refusals(f).filter(c => c === 'human-required').length, 6); assert.equal(f.store.all("SELECT 1 FROM events WHERE json_extract(body,'$.action')='record.write'").length, 0); }); test('a stale base refuses stale-base and writes nothing', async t => { const f = await fixture(t); const newer = f.save('# Agent edit\n', 'coder.builder'), before = state(f); await reject(f.call(f.human, 'project.file.save', { project: 'stack', path: 'spec/north-star.md', base: f.commit, content: '# Mine\n' }), 'stale-base'); assert.deepEqual(state(f), before); assert.equal(git(f.repo, 'show', `HEAD:spec/north-star.md`), '# Agent edit'); assert.ok(refusals(f).includes('stale-base')); const ok = await f.call(f.human, 'project.file.save', { project: 'stack', path: 'spec/north-star.md', base: newer, content: '# Mine\n' }); assert.match(ok.commit, /^[0-9a-f]{40}$/); }); test('paths outside the spec and docs refuse, as do unknown projects, missing files and odd arguments', async t => { const f = await fixture(t), before = state(f); const save = (over = {}) => f.call(f.human, 'project.file.save', { project: 'stack', path: 'spec/north-star.md', base: f.commit, content: '# X\n', ...over }); for (const path of ['project.json', 'requirements/.gitkeep', 'spec/other.md', 'docs/sub/deep.md', '../x.md', 'docs/a.txt']) await reject(save({ path }), 'project-record-path'); for (const path of ['project.json', 'docs/sub/deep.md']) await reject(f.call(f.human, 'project.file', { project: 'stack', path }), 'project-read-path'); await reject(save({ path: 'docs/new.md' }), 'project-read-missing'); await reject(save({ project: 'missing' }), 'project-not-declared'); await reject(save({ base: 'abc' }), 'project-record-invalid'); await reject(save({ content: '' }), 'project-record-invalid'); for (const args of [{}, { project: 'stack' }, { project: 'stack', path: 'spec/north-star.md', base: f.commit }, { project: 'stack', path: 'spec/north-star.md', base: f.commit, content: '# X\n', extra: 1 }]) await reject(f.call(f.human, 'project.file.save', args), 'invalid-request'); await reject(f.call(f.human, 'project.files', { project: 'stack', path: 'docs/README.md' }), 'invalid-request'); await reject(f.call(f.human, 'project.file', { project: 'stack' }), 'invalid-request'); assert.deepEqual(state(f), before); }); test('without a project adapter the file verbs refuse project-not-configured', async t => { const f = await fixture(t, { adapter: false }); for (const verb of PROJECT_FILE_VERBS) await reject(f.call(f.human, verb, { project: 'stack' }), 'project-not-configured'); }); async function raw(path, line) { const socket = connect(path); let input = ''; socket.on('data', b => { input += b; }); await new Promise(resolve => { socket.on('connect', resolve); }); socket.on('error', () => {}); socket.write(line); await new Promise(resolve => { socket.on('close', resolve); socket.on('end', resolve); }); return JSON.parse(input); } async function socketFixture(t) { const f = await fixture(t), path = join(f.root, 'bus.sock'); const server = await serve({ broker: f.broker, path, authenticateHuman: () => f.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) }); t.after(() => server.close()); return { ...f, path, human: new Client({ path, human: { fixture: true } }) }; } test('on the socket a save may carry 1 MiB of text; every other request keeps the 64 KiB limit', async t => { const f = await socketFixture(t); const big = 'x'.repeat(1024 * 1024), args = { project: 'stack', path: 'docs/README.md', base: (await f.human.call('project.file', { project: 'stack', path: 'docs/README.md' })).commit }; await reject(f.human.call('project.file.save', { ...args, content: big + 'y' }), 'project-record-invalid'); await reject(f.human.call('project.file.save', { ...args, content: big + 'x'.repeat(70000) }), 'request-too-large'); await reject(f.human.call('agents', { pad: 'x'.repeat(70000) }), 'request-too-large'); await reject(f.human.call('project.file', { project: 'stack', path: 'docs/README.md', pad: 'x'.repeat(70000) }), 'request-too-large'); const saved = await f.human.call('project.file.save', { ...args, content: big }); assert.equal(git(f.repo, 'show', `${saved.commit}:docs/README.md`).length, big.length); const open = await f.human.call('project.file', { project: 'stack', path: 'docs/README.md' }); assert.equal(open.content.length, big.length); }); test('a request that names the save verb first and another verb last is refused at 64 KiB', async t => { const f = await socketFixture(t); const pad = 'x'.repeat(100000); const sneaky = `{"human":{"fixture":true},"verb":"project.file.save","verb":"agents","args":{"pad":"${pad}"}}\n`; assert.deepEqual(await raw(f.path, sneaky), { ok: false, error: 'request-too-large', ...refusalGuidance('request-too-large') }); const unmarked = await raw(f.path, JSON.stringify({ human: { fixture: true }, verb: 'agents', args: { pad } }) + '\n'); assert.deepEqual(unmarked, { ok: false, error: 'request-too-large', ...refusalGuidance('request-too-large') }); const over = await raw(f.path, JSON.stringify({ human: { fixture: true }, verb: 'project.file.save', args: { pad: 'x'.repeat(1024 * 1024 + 80000) } }) + '\n'); assert.deepEqual(over, { ok: false, error: 'request-too-large', ...refusalGuidance('request-too-large') }); }); test('two saves racing for one repository make one commit', async t => { const f = await socketFixture(t); const base = (await f.human.call('project.file', { project: 'stack', path: 'docs/README.md' })).commit; const before = git(f.repo, 'rev-parse', 'HEAD'); const results = await Promise.allSettled(['one', 'two'].map(name => new Client({ path: f.path, human: { fixture: true } }).call('project.file.save', { project: 'stack', path: 'docs/README.md', base, content: `# ${name}\n` }))); assert.equal(results.filter(r => r.status === 'fulfilled').length, 1); assert.match(results.find(r => r.status === 'rejected').reason.code, /^(project-busy|stale-base)$/); assert.equal(git(f.repo, 'rev-list', '--count', `${before}..HEAD`), '1'); });