// C2-14 (#1728): one scratch-root acceptance driver. No live host or real model. // The human uses bindHuman in-process because a development agent cannot prove human CLI ancestry. // The creative session is real createLauncher -> runner -> C2-06's scripted citation adapter. // All 17 steps run against landed APIs. It runs standalone from scripts/test-case2.sh, as the landing gate runs it. import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import { scratchCa, fakeGitea } from '../../packages/seat/tests/fake-gitea.mjs'; import { BASE, MARKER, git, mappingEntry } from '../../packages/library/tests/helpers.mjs'; import * as library from '../../packages/library/src/index.mjs'; import { launchFixture } from './launch.mjs'; import { consoleAcceptance } from './console.mjs'; import { FETCH_TOKEN, SEAT_TOKEN, HUMAN_TOKEN, FIXTURE_CREDENTIALS } from './fixture-credentials.mjs'; const USER = 'books-bot', BUSINESS = 'acme'; const CHARACTER = 'Book One/Characters/Mara Vell.md'; const SUPPORT = 'Book One/Notes/Eyes.md'; const WRITE = 'Book One/Notes/Agent.md'; const EDITOR = { name: 'Fixture author', email: 'author@example.test', user: 'writer' }; const secrets = FIXTURE_CREDENTIALS; const redact = value => secrets.reduce((s, token) => s.replaceAll(token, '[fixture credential]'), String(value)); let failures = 0; const out = line => process.stdout.write(redact(line) + '\n'); const expect = (cond, message) => { if (!cond) throw new Error(message); }; async function step(id, text, fn) { try { await fn(); out(`ok ${id} ${text}`); } catch (e) { failures++; out(`not ok ${id} ${text}: ${e?.code ?? ''} ${String(e?.message ?? e).split('\n')[0].slice(0, 300)}`.trimEnd()); } } async function refuses(promise, code) { try { await promise; } catch (e) { if (e?.code === code) return; throw new Error(`refused ${e?.code ?? 'unknown'}, expected ${code}`); } throw new Error(`answered, expected ${code}`); } async function until(fn, label, ms = 20000) { const end = Date.now() + ms; while (Date.now() < end) { const result = await fn(); if (result) return result; await new Promise(r => setTimeout(r, 30)); } throw new Error(`timed out: ${label}`); } function holdsToken(dir) { for (const name of readdirSync(dir, { recursive: true })) { const file = join(dir, name); let st; try { st = statSync(file); } catch { continue; } if (st.isFile() && secrets.some(value => readFileSync(file).includes(value))) return file; } return null; } function readEvents(dataRoot, where = "kind='action.refused'") { const db = new DatabaseSync(join(dataRoot, 'bus', 'bus.sqlite'), { readOnly: true }); try { return db.prepare(`SELECT body FROM events WHERE ${where} ORDER BY seq`).all().map(row => JSON.parse(row.body)); } finally { db.close(); } } const cleanups = []; let closing = null; const close = () => (closing ??= (async () => { const errors = []; for (const cleanup of cleanups.reverse()) { try { await cleanup(); } catch { errors.push('cleanup failed'); } } if (errors.length) throw new Error('acceptance resource cleanup failed'); })()); for (const [name, status] of [['SIGTERM', 143], ['SIGINT', 130]]) process.once(name, () => { void close().finally(() => process.exit(status)); }); async function main(root) { const work = join(root, 'her', 'books'); mkdirSync(work, { recursive: true }); git(work, 'init', '-q', '-b', 'main'); const files = { ...BASE, [SUPPORT]: '# Eye notes\nGrey eyes are in the character record.\n' }; for (const [path, text] of Object.entries(files)) { mkdirSync(dirname(join(work, path)), { recursive: true }); writeFileSync(join(work, path), text); } git(work, 'add', '-A'); git(work, 'commit', '-qm', 'her first commit'); const first = git(work, 'rev-parse', 'HEAD'); const tls = scratchCa(join(root, 'ca')); const gitea = await fakeGitea({ dir: join(root, 'gitea'), tls, user: USER, token: FETCH_TOKEN, users: { [USER]: FETCH_TOKEN, 'acme-bean': SEAT_TOKEN, [EDITOR.user]: HUMAN_TOKEN } }); cleanups.push(() => gitea.close()); const remote = gitea.repo('writer', 'books'); mkdirSync(dirname(remote), { recursive: true }); git(root, 'clone', '-q', '--bare', '--no-local', work, remote); const push = (changed, { from = null } = {}) => { if (from) git(work, 'reset', '-q', '--hard', from); for (const [path, text] of Object.entries(changed)) { mkdirSync(dirname(join(work, path)), { recursive: true }); writeFileSync(join(work, path), text); } git(work, 'add', '-A'); git(work, 'commit', '-qm', 'her next commit'); git(work, 'push', '-q', '--force', remote, 'HEAD:refs/heads/main'); return git(work, 'rev-parse', 'HEAD'); }; const remoteHead = () => git(remote, 'rev-parse', 'refs/heads/main'); const entry = { ...mappingEntry(), remote: `${gitea.origin}/writer/books.git`, user: USER, editor: EDITOR }; entry.universes.harbor.supporting = ['Book One/Notes/']; const f = await launchFixture(root, entry, { fetchToken: FETCH_TOKEN, seatToken: SEAT_TOKEN, humanToken: HUMAN_TOKEN, ca: tls.ca }); cleanups.push(() => f.close()); const { runtime: bus, dataRoot } = f, broker = bus.broker; const human = broker.bindHuman({ business: BUSINESS, human: 'jason', via: 'cli', outsideAgent: true }); const call = (cap, verb, args = {}) => Promise.resolve().then(() => broker.request(cap, { verb, args })); const humanCall = (verb, args = {}) => call(human, verb, args); const sync = async (args = {}) => { for (let i = 0; ; i++) { try { return await humanCall('library.sync', { project: 'books', ...args }); } catch (e) { if (e?.code !== 'library-sync-busy' || i >= 100) throw e; await new Promise(r => setTimeout(r, 100)); } } }; const status = () => humanCall('library.status', { project: 'books' }); const mapping = library.checkMapping(entry); const mirror = library.mirrorPath({ business: f.business, project: 'books', dataRoot }); const source = async () => ({ repo: mirror, mapping, commit: (await status()).served }); await step('S1a', 'her bare layout classifies canon, manuscript, playground, unmapped and second-universe paths', () => { expect(git(remote, 'rev-parse', '--is-bare-repository') === 'true', 'remote is not bare'); const of = p => library.classify(mapping, p); expect(of(CHARACTER).scope === 'canon' && of(CHARACTER).type === 'character', 'character'); expect(of('Book One/Chapters/Chapter 01.md').type === 'manuscript', 'chapter'); expect(of('Book One/Playground/What If.md').scope === 'playground', 'playground'); expect(of('Inbox Note.md').scope === 'unmapped', 'inbox'); expect(of('Second World/Chapters/01.md').universe === 'second', 'second universe'); }); await step('S1b', 'a supporting folder and her editor are part of the checked mapping', async () => { expect(library.classify(mapping, SUPPORT).scope === 'supporting', 'supporting scope'); expect(library.supportingPath(mapping, 'harbor', WRITE, await library.listTree(remote, first)) === WRITE, 'supporting write path'); expect(mapping.editor.name === EDITOR.name && mapping.editor.email === EDITOR.email, 'editor'); }); await step('S2a', 'library.sync fetches her bare repo and serves its head', async () => { const state = await sync(); expect(state.served === first && state.served === remoteHead(), 'wrong served commit'); expect(state.lastError === null && state.diverged === null, 'sync error or divergence'); }); await step('S2b', 'library.file reads canon and playground at served, and refuses an unmapped file', async () => { const canon = await humanCall('library.file', { project: 'books', path: CHARACTER }); expect(canon.commit === first && canon.content.includes(MARKER), 'canon file'); const play = await humanCall('library.file', { project: 'books', path: 'Book One/Playground/What If.md' }); expect(play.content.includes(MARKER), 'playground file'); await refuses(humanCall('library.file', { project: 'books', path: 'Inbox Note.md' }), 'project-read-path'); }); await step('S2c', 'her fast-forward push is served by the next sync', async () => { const next = push({ 'Book One/Places/Harbor Town.md': `---\ntype: place\n---\nSalt and tar. ${MARKER}\n` }); expect((await sync()).served === next, 'fast-forward not served'); }); await step('S2d', 'fixture credentials occur nowhere under the data root, including the mirror', () => { expect(holdsToken(dataRoot) === null, 'credential found under the data root'); }); let thread, asked, reply, writeCommit, writeNotice; let citedCommit; await step('S3a', 'she makes a creative thread and asks in it', async () => { const project = (await humanCall('project.create', { name: 'books', repo: 'books' })).id; thread = (await humanCall('thread.create', { project, name: 'Book One', instance: 'bean' })).id; const looked = await library.get(await source(), { universe: 'harbor', path: CHARACTER }); expect(looked && looked.scope === 'canon', 'lookup missing'); const index = looked.lines.findIndex(([line, text]) => Number.isSafeInteger(line) && text.includes('Grey eyes.')); expect(index >= 0, 'lookup quote missing'); citedCommit = looked.commit; const cite = { path: CHARACTER, commit: citedCommit, lines: [looked.lines[index][0], looked.lines[index][0]], quote: 'Grey eyes.', as: 'canon' }; const valid = text => JSON.stringify({ text, citations: [cite] }); const bad = JSON.stringify({ text: 'Playground is canon.', citations: [{ path: 'Book One/Playground/What If.md', commit: citedCommit, lines: [4, 4], quote: MARKER, as: 'canon' }] }); f.setPlan([{ lookups: [{ universe: 'harbor', path: CHARACTER }], text: valid('Grey eyes.') }, { text: bad }, { text: valid('The playground cannot establish canon.') }]); asked = (await humanCall('thread.send', { thread, body: "What colour are Mara Vell's eyes?" })).id; }); await step('S3b', 'the real launcher runs the C2-06 fake harness and stores its reply in her thread', async () => { reply = await until(async () => (await humanCall('thread.messages', { thread })).find(m => m.in_reply_to === asked && m.class === 'RESULT'), 'launcher reply'); expect(reply.from_role === 'bean' && f.cap(thread), 'reply did not use managed bean run'); const record = f.records.find(r => r.thread === thread); expect(record && Number.isSafeInteger(record.pid) && record.pid > 1, 'no launched process record'); const runDir = dirname(join(dataRoot, 'launches', BUSINESS, record.run, 'session.json')); expect(Number(readFileSync(join(runDir, 'fixture-count'), 'utf8')) === 1, 'scripted adapter was not called exactly once'); }); await step('S3c', 'her stored reply carries a verified lookup citation at the served commit', () => { const body = JSON.parse(reply.body); expect(body.text === 'Grey eyes.' && body.citations[0].commit === citedCommit, 'wrong reply'); const record = f.records.find(r => r.thread === thread); const lookups = readFileSync(join(dataRoot, 'launches', BUSINESS, record.run, 'fixture-lookups.jsonl'), 'utf8').trim().split('\n').map(line => JSON.parse(line)); expect(lookups.length === 1 && lookups[0].path === CHARACTER && lookups[0].commit === citedCommit && lookups[0].scope === 'canon' && lookups[0].lines.some(([line, text]) => line === body.citations[0].lines[0] && text.includes('Grey eyes.')), 'managed harness lookup did not supply the cited source'); expect(reply.check?.citations?.length === 1 && reply.check.citations[0].scope === 'canon', 'missing stored guard check'); const events = readEvents(dataRoot, "kind='action.allowed'"); expect(events.some(e => e.action === 'message.checked' && e.message === reply.id && e.check.citations[0].scope === 'canon'), 'no stored check event'); }); await step('S4', 'a playground-as-canon reply refuses, and the runner retries without storing the bad body', async () => { const next = (await humanCall('thread.send', { thread, body: 'Can the playground establish canon?' })).id; const second = await until(async () => (await humanCall('thread.messages', { thread })).find(m => m.in_reply_to === next && m.class === 'RESULT'), 'guard retry'); expect(JSON.parse(second.body).text === 'The playground cannot establish canon.', 'retry answer'); const history = await humanCall('thread.messages', { thread }); expect(!history.some(m => m.body.includes('Playground is canon.')), 'refused reply persisted'); expect(readEvents(dataRoot).some(e => e.code === 'playground-cited-as-canon'), 'missing refusal'); const record = f.records.find(r => r.thread === thread); expect(Number(readFileSync(join(dataRoot, 'launches', BUSINESS, record.run, 'fixture-count'), 'utf8')) === 3, 'retry count'); }); await step('S5a', 'her force-push holds library-diverged without moving served', async () => { const before = (await status()).served; const forced = push({ 'Book One/Places/Harbor Town.md': `---\ntype: place\n---\nRewritten. ${MARKER}\n` }, { from: first }); await refuses(sync(), 'library-diverged'); const state = await status(); expect(state.served === before && state.diverged?.commit === forced, 'force-push was not held'); expect(readEvents(dataRoot).some(e => e.code === 'library-diverged'), 'missing divergence refusal'); }); await step('S5b', 'a stale accept refuses, then her accepted tip becomes served', async () => { await refuses(sync({ accept: first }), 'library-accept-stale'); const tip = remoteHead(), state = await sync({ accept: tip }); expect(state.served === tip && state.diverged === null, 'tip not accepted'); }); // Write assertions are measured here, after divergence acceptance; setup is never write evidence. await step('S6', 'the fixture-granted seat writes one supporting commit with its trailer and a thread notice', async () => { const before = remoteHead(), requestsBefore = gitea.requests.length; const result = await call(f.cap(thread), 'library.doc.write', { universe: 'harbor', path: WRITE, base: null, content: '# Agent note\nA supporting observation.\n' }); writeCommit = remoteHead(); expect(result.commit === writeCommit && result.path === WRITE, 'write result'); expect(writeCommit !== before && git(remote, 'rev-list', '--count', `${before}..${writeCommit}`) === '1', 'write must add exactly one commit'); expect(git(remote, 'show', '-s', '--format=%P', writeCommit) === before, 'write parent'); expect(git(remote, 'show', '-s', '--format=%B', writeCommit).includes('Mosaic-Agent: acme/bean'), 'missing agent trailer'); expect(git(remote, 'show', '-s', '--format=%an <%ae>', writeCommit) === 'Bean fixture ', 'seat author'); expect(gitea.requests.slice(requestsBefore).some(r => r.method === 'POST' && r.user === 'acme-bean' && r.path.endsWith('/git-receive-pack')), 'seat credential not used for push'); writeNotice = (await humanCall('thread.messages', { thread })).find(m => { try { const body = JSON.parse(m.body); return body.notice === 'library-write' && body.commit === writeCommit; } catch { return false; } }); expect(writeNotice?.from_role === 'bean', 'notice missing from her thread'); const noticeBody = JSON.parse(writeNotice.body); expect(Object.keys(noticeBody).sort().join(',') === 'commit,notice,op,path' && noticeBody.notice === 'library-write' && noticeBody.op === 'write' && noticeBody.path === WRITE && noticeBody.commit === writeCommit, 'notice body'); }); await step('S7', 'a story-path write refuses library-write-story-path and leaves her remote unchanged', async () => { const before = remoteHead(), served = (await status()).served; await refuses(call(f.cap(thread), 'library.doc.write', { universe: 'harbor', path: 'Book One/Chapters/Chapter 01.md', base: null, content: 'Unwanted story edit.\n' }), 'library-write-story-path'); expect(remoteHead() === before && (await status()).served === served, 'refusal moved a ref'); }); await step('S8', 'her revert of the agent write adds exactly one editor-authored commit', async () => { const before = remoteHead(), requestsBefore = gitea.requests.length; // A2 (sage-3596): revert names the business project key as well as the commit. const result = await humanCall('library.revert', { project: 'books', commit: writeCommit }); const after = remoteHead(); expect(after !== before && git(remote, 'rev-list', '--count', `${before}..${after}`) === '1', 'revert commit count'); expect(git(remote, 'show', '-s', '--format=%P', after) === before, 'revert must have exactly one parent'); expect(gitea.requests.slice(requestsBefore).some(r => r.method === 'POST' && r.user === EDITOR.user && r.path.endsWith('/git-receive-pack')), 'revert did not use her credential'); expect(result.reverted === writeCommit && result.commit === after, 'revert result'); expect(!git(remote, 'ls-tree', '-r', '--name-only', after).split('\n').includes(WRITE), 'revert did not remove the newly created note'); expect(git(remote, 'show', '-s', '--format=%an <%ae>', after) === `${EDITOR.name} <${EDITOR.email}>`, 'revert editor'); }); await step('S9', 'her library.file.save adds one commit with her editor as author', async () => { const before = remoteHead(), requestsBefore = gitea.requests.length, base = git(remote, 'rev-parse', `${before}:${CHARACTER}`); const content = git(remote, 'show', `${before}:${CHARACTER}`) + '\nHer saved note.\n'; const result = await humanCall('library.file.save', { project: 'books', path: CHARACTER, base, content }); const after = remoteHead(); expect(after !== before && git(remote, 'rev-list', '--count', `${before}..${after}`) === '1', 'save commit count'); expect(git(remote, 'show', '-s', '--format=%P', after) === before, 'save must have exactly one parent'); expect(git(remote, 'show', '-s', '--format=%an <%ae>', after) === `${EDITOR.name} <${EDITOR.email}>`, 'save editor'); expect(git(remote, 'show', `${after}:${CHARACTER}`).endsWith('Her saved note.'), 'save content'); expect(result.commit === after && result.path === CHARACTER, 'save result'); expect(gitea.requests.slice(requestsBefore).some(r => r.method === 'POST' && r.user === EDITOR.user && r.path.endsWith('/git-receive-pack')), 'save did not use her credential'); }); await step('S10', 'the real Console renders her checked reply and the broker write notice', async () => { expect(writeNotice && writeCommit, 'actual write notice is required'); await consoleAcceptance({ humanCall, thread, reply, writeNotice, writeCommit, cleanups }); }); expect(holdsToken(dataRoot) === null, 'credential persisted during the later steps'); } const root = process.argv[2]; if (!root) { process.stderr.write('usage: node scripts/case2/e2e.mjs \n'); process.exit(2); } try { await main(resolve(root)); } catch (e) { failures++; process.stderr.write(redact(`e2e: stopped: ${e?.code ?? ''} ${String(e?.message ?? e).split('\n')[0]}`) + '\n'); } finally { await close(); } if (failures) process.exitCode = 1;