Files
stack/scripts/case2/e2e.mjs
T
jason.woltje 04e1ce24cc row 227 R2 (#1728): run test-case2.sh standalone, without the slot controller
Remove the R1 slot-controller ties from the C2-14 fixture: the ROW227_CONTROL_ROOT
check, the native permit wait in bindLaunch, the fixture-cleanup.json write, the
priority.mjs preload and the owned-bwrap.sh override. The launcher uses its own bwrap.

The shell runs the driver from an explicit env -i environment (PATH, LANG, a sandbox
HOME, XDG dirs and TMPDIR, MOSAIC_CONFIG, no global git config) as its own process
group. On a signal the driver gets 3 s by the clock, then its group and any process
whose cwd is in the sandbox get SIGKILL, inside the landing gate's 5 s grace.

The fixture credentials are defined only in scripts/case2/fixture-credentials.mjs.
The new scripts/case2/leak-check.mjs reads them from there and never prints them.
2026-11-02 15:41:17 -06:00

265 lines
19 KiB
JavaScript

// 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: '[email protected]', 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 <[email protected]>', '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 <scratch dir>\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;