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.
265 lines
19 KiB
JavaScript
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;
|