Merge row 227 (C2-14, #1728) R3: Case 2 end-to-end suite that runs alone with a cleanup that cannot touch other processes
This commit is contained in:
commit
ea02c061e4
171 files changed
+41989
No files matched your search
@@ -0,0 +1,14 @@
|
||||
// C2-14 adds an observed real library tool read before C2-06's scripted reply adapter.
|
||||
// The reply plan is fixture input, never evidence that a lookup or guard actually ran.
|
||||
import { appendFileSync, existsSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { callTool } from '../../packages/harness/src/tools.mjs';
|
||||
const root = dirname(process.env.MOSAIC_TURN_MARKER);
|
||||
const count = join(root, 'fixture-count');
|
||||
const n = existsSync(count) ? Number(readFileSync(count, 'utf8')) : 0;
|
||||
const step = JSON.parse(readFileSync(join(root, 'fixture-steps.json'), 'utf8'))[n];
|
||||
for (const args of step?.lookups ?? []) {
|
||||
const result = await callTool(process.env.MOSAIC_TOOL_SOCKET, 'library_get', args);
|
||||
appendFileSync(join(root, 'fixture-lookups.jsonl'), JSON.stringify(result) + '\n');
|
||||
}
|
||||
await import('../../packages/harness/tests/fixtures/citation-adapter.mjs');
|
||||
@@ -0,0 +1,43 @@
|
||||
// C2-14 S10: actual Console HTTP chat endpoints and browser DOM over the same acceptance bus.
|
||||
// The unrelated Board is a local 503 fixture. No chat response, citation check or notice is stubbed.
|
||||
import { createServer } from 'node:http';
|
||||
import { startServer } from '../../packages/webui/src/serve.mjs';
|
||||
import { consoleBus } from '../../packages/webui/src/console-bus.mjs';
|
||||
import { browser } from '../../packages/webui/tests/browser.mjs';
|
||||
|
||||
const shutdown = server => new Promise((resolve, reject) => {
|
||||
server.close(error => error ? reject(error) : resolve());
|
||||
server.closeAllConnections?.();
|
||||
});
|
||||
const expect = (value, label) => { if (!value) throw new Error(label); };
|
||||
export async function consoleAcceptance({ humanCall, thread, reply, writeNotice, writeCommit, cleanups }) {
|
||||
const board = createServer((req, res) => { res.writeHead(503); res.end('Acceptance Board is not configured.'); });
|
||||
await new Promise(resolve => board.listen(0, '127.0.0.1', resolve));
|
||||
cleanups.push(() => shutdown(board));
|
||||
const options = consoleBus(humanCall);
|
||||
const server = await startServer({ port: 0, board: `http://127.0.0.1:${board.address().port}`, ...options, log: () => {} });
|
||||
cleanups.push(() => shutdown(server));
|
||||
const origin = `http://127.0.0.1:${server.address().port}`;
|
||||
const response = await fetch(`${origin}/api/chat/threads/${thread}/messages`);
|
||||
expect(response.ok, 'Console message endpoint refused');
|
||||
const body = await response.json();
|
||||
expect(Array.isArray(body.rows), 'Console message shape');
|
||||
const checked = body.rows.find(m => m.id === reply.id), notice = body.rows.find(m => m.id === writeNotice.id);
|
||||
expect(checked?.body === reply.body && checked.check?.citations?.[0]?.scope === 'canon', 'Console dropped stored reply/check');
|
||||
expect(notice?.body === writeNotice.body, 'Console dropped broker notice');
|
||||
const view = await browser({ machine: false });
|
||||
cleanups.push(() => view.close());
|
||||
await view.navigate(`${origin}/#/chat/threads/${thread}`);
|
||||
const rendered = await view.evaluate(`(async () => {
|
||||
for (let i = 0; i < 150; i++) {
|
||||
const bodies = [...document.querySelectorAll('.chat-log .chat-body')].map(e => e.textContent);
|
||||
const notices = [...document.querySelectorAll('.chat-notice > span:first-child')].map(e => e.textContent);
|
||||
const canon = [...document.querySelectorAll('.chat-cites')].some(e => e.textContent.includes('canon'));
|
||||
if (bodies.some(t => t === 'Grey eyes.') && canon && notices.some(t => t === ${JSON.stringify(`bean wrote ${JSON.parse(writeNotice.body).path} @${writeCommit.slice(0, 7)}`)}))
|
||||
return { reply: true, canon, notice: true };
|
||||
await new Promise(resolve => setTimeout(resolve, 40));
|
||||
}
|
||||
return { reply: false, canon: false, notice: false };
|
||||
})()`);
|
||||
expect(rendered.reply && rendered.canon && rendered.notice, 'Console did not render actual checked reply and write notice');
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
// 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;
|
||||
@@ -0,0 +1,8 @@
|
||||
// Made-up fixture credentials for C2-14 (#1728). Never a live credential. No other file under
|
||||
// scripts/ holds them: the driver (which hands them to the fake Gitea), the fixture launcher (the
|
||||
// fake pi/zai key) and the shell's leak check import them from here.
|
||||
export const FETCH_TOKEN = 'fx227-fetch-q7-2270';
|
||||
export const SEAT_TOKEN = 'fx227-seat-q7-2270';
|
||||
export const HUMAN_TOKEN = 'fx227-human-q7-2270';
|
||||
export const FAKE_ZAI_KEY = 'fx227-zai-q7-2270';
|
||||
export const FIXTURE_CREDENTIALS = Object.freeze([FETCH_TOKEN, SEAT_TOKEN, HUMAN_TOKEN, FAKE_ZAI_KEY]);
|
||||
@@ -0,0 +1,130 @@
|
||||
// C2-14 fixture: the real host/launcher with C2-06's scripted citation adapter. No model binary runs.
|
||||
import { chmodSync, mkdirSync, writeFileSync, existsSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { BusError, writeGrantEvent } from '../../packages/bus/src/broker.mjs';
|
||||
import { Store } from '../../packages/bus/src/store.mjs';
|
||||
import { startBroker } from '../../packages/bus/src/runtime.mjs';
|
||||
import { grantDecision } from '../../packages/bus/tests/host-fixtures.mjs';
|
||||
import { launchDoc } from '../../packages/business/tests/helpers.mjs';
|
||||
import { fakeHarnesses, fixture } from '../../packages/cli/tests/helpers.mjs';
|
||||
import { bootConfig, loadSystem } from '../../packages/cli/src/config.mjs';
|
||||
import { createLauncher } from '../../packages/cli/src/launcher.mjs';
|
||||
import { SESSION_CREDENTIALS } from '../../packages/seat/src/session.mjs';
|
||||
import * as library from '../../packages/library/src/index.mjs';
|
||||
import { FAKE_ZAI_KEY } from './fixture-credentials.mjs';
|
||||
|
||||
const ADAPTER = fileURLToPath(new URL('./citation-adapter.mjs', import.meta.url));
|
||||
const REPLY_ADAPTER = fileURLToPath(new URL('../../packages/harness/tests/fixtures/citation-adapter.mjs', import.meta.url));
|
||||
const ROTATE = '2099-01-01';
|
||||
const AT = '2026-10-24T00:00:00.000Z';
|
||||
const shell = value => "'" + String(value).replaceAll("'", "'\\''") + "'";
|
||||
|
||||
// A fixture grant is part of the brief. It uses the normal store shape; no executor/network mint or
|
||||
// role-policy modification occurs. This is deliberately distinct from the row287 grant acceptance.
|
||||
function seedGrant(dataRoot, storeRoot, token) {
|
||||
const dir = join(storeRoot, 'acme', 'bean');
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
writeFileSync(join(dir, 'gitea'), token + '\n', { mode: 0o600 });
|
||||
const store = new Store(dataRoot);
|
||||
try {
|
||||
const decision = grantDecision(store, { business: 'acme', grant: {
|
||||
method: 'gitea', agent: 'bean', target: { projects: ['books'] }, rotateBy: ROTATE,
|
||||
} });
|
||||
store.run('INSERT INTO gitea_users(business,agent,user,id,nonce,decision) VALUES(?,?,?,?,?,?)',
|
||||
'acme', 'bean', 'acme-bean', 1, '2270227022702270', decision);
|
||||
store.run('INSERT INTO gitea_grants(business,agent,user,token_id,token_name,projects,rotate_by,decision) VALUES(?,?,?,?,?,?,?,?)',
|
||||
'acme', 'bean', 'acme-bean', 1, 'fixture-227', '["books"]', ROTATE, decision);
|
||||
writeGrantEvent(store, AT, 'acme', { operation: 'grant.gitea', decision }, decision);
|
||||
writeGrantEvent(store, AT, 'acme', { action: 'grant.applied', method: 'gitea', agent: 'bean',
|
||||
grantDecision: decision, user: 'acme-bean', userId: 1, tokenId: 1, forks: [],
|
||||
repositories: [{ project: 'books', repository: 'fixture', level: 'write' }] }, 'bean');
|
||||
} finally { store.close(); }
|
||||
}
|
||||
|
||||
export async function launchFixture(root, entry, { fetchToken, seatToken, humanToken, ca }) {
|
||||
const configDir = join(root, 'config'), storeRoot = join(configDir, 'secrets', 'agents');
|
||||
const slotDir = join(configDir, 'secrets', 'acme');
|
||||
mkdirSync(slotDir, { recursive: true, mode: 0o700 });
|
||||
for (const [slot, token] of [[library.SLOT, fetchToken], [library.HUMAN_SLOT, humanToken]])
|
||||
writeFileSync(join(slotDir, slot), token + '\n', { mode: 0o600 });
|
||||
const plan = join(root, 'citation-plan.json');
|
||||
writeFileSync(plan, '[]\n', { mode: 0o600 });
|
||||
const f = fixture(root, 'acme', doc => {
|
||||
// The unused factory roles remain valid, but no tracker is configured or polled.
|
||||
delete doc.tracker;
|
||||
doc.projects = { books: entry };
|
||||
doc.roles.chief.credentials = { gitea: { file: join(slotDir, library.SLOT), rotateBy: ROTATE } };
|
||||
doc.roles.bean = { definition: 'companion',
|
||||
credentials: { gitea: { store: true, rotateBy: ROTATE } },
|
||||
identity: { name: 'Bean fixture', email: '[email protected]', gitea: { url: new URL(entry.remote).origin, user: 'acme-bean' } },
|
||||
publish: ['books'], vars: { harness: 'pi', model: 'claude-sonnet-5-5', thinking: 'high' } };
|
||||
doc.launch = { ...launchDoc('chief', ['bean'], { opus: 1, sonnet: 1 }), threadIdleSeconds: 3600 };
|
||||
doc.plugins = { ...doc.plugins, channel: ['webui'] };
|
||||
doc.replyLimits = { requireCitation: true, citationsPerReply: 20 };
|
||||
return doc;
|
||||
});
|
||||
chmodSync(configDir, 0o700);
|
||||
for (const key of SESSION_CREDENTIALS.flat()) delete f.env[key];
|
||||
// Joined P3 keeps its real preflight; this fake route owns only its made-up ZAI key.
|
||||
for (const key of ['OPENAI_API_KEY', 'OPENROUTER_API_KEY', 'ZAI_API_KEY']) delete f.env[key];
|
||||
f.env.ZAI_API_KEY = FAKE_ZAI_KEY;
|
||||
seedGrant(f.dataRoot, storeRoot, seatToken);
|
||||
const system = loadSystem({ env: f.env });
|
||||
const { trackers, ...boot } = bootConfig({ system, businessId: 'acme', env: f.env });
|
||||
if (trackers !== undefined) throw new Error('acceptance fixture must not configure tracker polling');
|
||||
const command = (kind, url, opts) => {
|
||||
const c = library.syncCommand(kind, url, opts);
|
||||
return { argv: c.argv, env: { ...c.env, GIT_SSL_CAINFO: ca } };
|
||||
};
|
||||
const runtime = await startBroker({ ...boot, librarySync: { command, intervalMs: 3_600_000, configDir } });
|
||||
const hooks = [], cleanups = [], caps = new Map(), records = [], logs = [];
|
||||
let closing = null;
|
||||
const close = () => (closing ??= (async () => {
|
||||
try { for (const hook of hooks) await hook(0); }
|
||||
finally {
|
||||
try { await runtime.close(); }
|
||||
finally {
|
||||
for (const cleanup of cleanups.reverse()) await cleanup();
|
||||
const directory = join(f.dataRoot, 'control', 'runtimes');
|
||||
const remaining = existsSync(directory) ? readdirSync(directory) : [];
|
||||
if (remaining.length) throw new Error('owned-runtime-cleanup-incomplete');
|
||||
}
|
||||
}
|
||||
})());
|
||||
try {
|
||||
const ops = {
|
||||
identity: m => runtime.identity(m.cap), authorizeLaunch: m => runtime.authorizeLaunch(m.cap, m.instance),
|
||||
refuse: m => runtime.refuse(m.cap, m.code, m.subject), endLaunch: m => runtime.endLaunch(m.record),
|
||||
credentialStatus: m => runtime.credentialStatus(m.business, m.role), threadsState: m => runtime.threadsState(m.business),
|
||||
threadIdle: m => runtime.threadIdle(m.record), mayLaunch: m => runtime.mayLaunch(m),
|
||||
landHeld: m => runtime.landHeld(m.record), landHolders: m => runtime.landHolders(m.business),
|
||||
landRelease: m => runtime.landRelease(m.record), sessionClears: m => runtime.sessionClears(m.business),
|
||||
lifecycleRequests: m => runtime.lifecycleRequests(m.business),
|
||||
};
|
||||
const wrap = fn => async value => {
|
||||
try { return await fn(value); }
|
||||
catch (e) { throw Object.assign(new Error('fixture host refused'), { code: e instanceof BusError || /^[a-z-]+$/.test(e?.code ?? '') ? e.code : 'fixture-host-refused' }); }
|
||||
};
|
||||
const host = { business: 'acme', path: runtime.path, dataRoot: f.dataRoot,
|
||||
op: wrap(m => { if (!Object.hasOwn(ops, m.op)) throw new BusError('invalid-host-request'); return ops[m.op](m); }),
|
||||
bindLaunch: wrap(async record => {
|
||||
const bound = runtime.bindLaunch(record);
|
||||
records.push(record); caps.set(record.thread, bound.cap);
|
||||
return bound;
|
||||
}),
|
||||
beforeClose: fn => void hooks.push(fn),
|
||||
};
|
||||
const adapter = join(root, 'citation-adapter.sh');
|
||||
writeFileSync(adapter, `cp ${shell(plan)} "$(dirname "$MOSAIC_TURN_MARKER")/fixture-steps.json"\nexec ${shell(process.execPath)} ${shell(ADAPTER)}\n`, { mode: 0o644 });
|
||||
const launcher = createLauncher({ host, system, env: f.env,
|
||||
makeLoader: await fakeHarnesses({ after: fn => cleanups.push(fn) }, { pi: adapter }, write =>
|
||||
write('channel', 'webui', `export * from ${JSON.stringify(new URL('../../packages/plugins/channel/webui/index.mjs', import.meta.url).href)};`)),
|
||||
readOnly: [ADAPTER, REPLY_ADAPTER, plan], harnessVersion: '0.85.1',
|
||||
sessionDefaults: { pollInterval: 50 }, threadTickMs: 50, log: line => logs.push(line),
|
||||
});
|
||||
await launcher.listen();
|
||||
return { ...f, runtime, launcher, logs, records, plan, cap: thread => caps.get(thread), close,
|
||||
business: boot.businesses.acme, setPlan: steps => writeFileSync(plan, JSON.stringify(steps) + '\n') };
|
||||
} catch (e) { await close(); throw e; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// C2-14 (#1728): the shell's check that no fixture credential reached the driver's output.
|
||||
// The values come from fixture-credentials.mjs, the one place they are defined, and are never
|
||||
// printed. Exit 0: none of the files holds one. Exit 1: one does. Exit 2: no file given, or a
|
||||
// file could not be read, so nothing was checked.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { FIXTURE_CREDENTIALS } from './fixture-credentials.mjs';
|
||||
|
||||
const files = process.argv.slice(2);
|
||||
if (!files.length || !FIXTURE_CREDENTIALS.length) process.exit(2);
|
||||
let found = false;
|
||||
for (const file of files) {
|
||||
let bytes;
|
||||
try { bytes = readFileSync(file); } catch { process.exit(2); }
|
||||
if (FIXTURE_CREDENTIALS.some(value => bytes.includes(value))) found = true;
|
||||
}
|
||||
process.exit(found ? 1 : 0);
|
||||
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env bash
|
||||
# C2-14 (#1728, row 227 R3): checks for scripts/case2/sandbox.sh, the sandbox guard and the literal
|
||||
# cwd match behind the kill sweep in scripts/test-case2.sh. The match runs against dummy symlinks in
|
||||
# a scratch directory. Nothing here sends a signal: kill is a stub that records its arguments, so
|
||||
# even a broken guard could not reach a process. One sweep call and the timing line read every
|
||||
# /proc/<pid>/cwd target, as the suite's sweep does, with a root that holds no process.
|
||||
# Run: bash scripts/case2/sandbox-check.sh (exit 0 when every check holds)
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/../.." || exit 2
|
||||
. scripts/case2/sandbox.sh || exit 2
|
||||
case "${TMPDIR:-/tmp}" in /*) ;; *) echo 'sandbox-check: TMPDIR must be absolute' >&2; exit 2 ;; esac
|
||||
made="$(mktemp -d)" || exit 2
|
||||
case "$made" in /?*) ;; *) exit 2 ;; esac
|
||||
W="$(cd -P -- "$made" && pwd -P)" && WID="$(sandbox_id "$W")" && sandbox_ok "$W" "$WID" || exit 2
|
||||
trap 'remove_sandbox "$W" "$WID"' EXIT
|
||||
kill() { printf '%s\n' "$*" >>"$W/kill-calls"; return 0; }
|
||||
PASS=0 FAIL=0
|
||||
expect() { # expect NAME WANT(0|1) COMMAND...: COMMAND exits 0 when WANT is 0, non-zero when 1
|
||||
local name=$1 want=$2 got
|
||||
shift 2
|
||||
"$@" 2>/dev/null; got=$?
|
||||
if { [ "$want" = 0 ] && [ "$got" = 0 ]; } || { [ "$want" = 1 ] && [ "$got" != 0 ]; }; then
|
||||
PASS=$((PASS+1)); echo "ok $name"
|
||||
else
|
||||
FAIL=$((FAIL+1)); echo "FAIL $name (exit $got)"
|
||||
fi
|
||||
}
|
||||
same() { # same NAME WANT GOT
|
||||
if [ "$2" = "$3" ]; then PASS=$((PASS+1)); echo "ok $1"
|
||||
else FAIL=$((FAIL+1)); printf 'FAIL %s\n want %q\n got %q\n' "$1" "$2" "$3"; fi
|
||||
}
|
||||
|
||||
# The guard. Roots with *, ? and [ are real directories here; the guard takes them as paths.
|
||||
for name in 'a[bc]' 's*t' 'q?x' 'ab' 'sXt' 'qZx' 'keep'; do mkdir -p -- "$W/$name/tmp.example/root"; done
|
||||
R="$W/a[bc]/tmp.example"
|
||||
RID=$(sandbox_id "$R")
|
||||
mkdir -- "$W/real" && ln -s -- "$W/real" "$W/alias"
|
||||
expect 'guard: the created root with its id' 0 sandbox_ok "$R" "$RID"
|
||||
expect 'guard: empty root' 1 sandbox_ok '' "$RID"
|
||||
expect 'guard: empty id' 1 sandbox_ok "$R" ''
|
||||
expect 'guard: relative root' 1 sandbox_ok 'scripts/case2' "$(sandbox_id scripts/case2)"
|
||||
expect 'guard: missing root' 1 sandbox_ok "$W/missing" "$RID"
|
||||
expect 'guard: / as root' 1 sandbox_ok / "$(sandbox_id /)"
|
||||
expect 'guard: trailing slash' 1 sandbox_ok "$R/" "$RID"
|
||||
expect 'guard: a . part' 1 sandbox_ok "$W/a[bc]/./tmp.example" "$RID"
|
||||
expect 'guard: a symlink as root' 1 sandbox_ok "$W/alias" "$(sandbox_id "$W/real")"
|
||||
expect 'guard: a path through a symlink' 1 sandbox_ok "$W/alias/../a[bc]/tmp.example" "$RID"
|
||||
expect 'guard: the right path, another id' 1 sandbox_ok "$R" "$(sandbox_id "$W/ab/tmp.example")"
|
||||
expect 'guard: a sibling with the root id' 1 sandbox_ok "$W/ab/tmp.example" "$RID"
|
||||
expect 'guard: root with *' 0 sandbox_ok "$W/s*t/tmp.example" "$(sandbox_id "$W/s*t/tmp.example")"
|
||||
expect 'guard: root with ?' 0 sandbox_ok "$W/q?x/tmp.example" "$(sandbox_id "$W/q?x/tmp.example")"
|
||||
expect 'guard: empty directory' 0 dir_empty "$W/real"
|
||||
touch -- "$W/real/.dot"
|
||||
expect 'guard: a dot entry is not empty' 1 dir_empty "$W/real"
|
||||
|
||||
# The sweep and the removal refuse on their own, before they read a link or remove anything.
|
||||
expect 'sweep: refuses an empty root' 1 sweep_sandbox '' ''
|
||||
expect 'sweep: refuses / as root' 1 sweep_sandbox / "$(sandbox_id /)"
|
||||
expect 'sweep: refuses a missing root' 1 sweep_sandbox "$W/missing" "$RID"
|
||||
expect 'remove: refuses an empty root' 1 remove_sandbox '' ''
|
||||
expect 'remove: refuses a root with the wrong id' 1 remove_sandbox "$W/keep" "$RID"
|
||||
expect 'remove: the refused root is still there' 0 test -d "$W/keep/tmp.example/root"
|
||||
expect 'sweep: a valid root with no process in it' 0 sweep_sandbox "$W/keep" "$(sandbox_id "$W/keep")"
|
||||
same 'sweep: no signal was asked for' '' "$(cat -- "$W/kill-calls" 2>/dev/null)"
|
||||
|
||||
# The match. Dummy links stand in for /proc/<pid>/cwd; a regular file stands in for a link this
|
||||
# user may not read (find prints an empty target for both).
|
||||
n=0
|
||||
link() { n=$((n+1)); mkdir -p -- "$W/links/$n"; ln -s -- "$1" "$W/links/$n/cwd"; LINKS+=("$W/links/$n/cwd"); }
|
||||
match() { # match ROOT: the indices of the dummy links cwd_links_in selects, space-separated
|
||||
local l out=''
|
||||
while IFS= read -r -d '' l; do l=${l#"$W/links/"}; out="$out ${l%/cwd}"; done \
|
||||
< <(cwd_links_in "$1" "${LINKS[@]}")
|
||||
printf '%s' "${out# }"
|
||||
}
|
||||
LINKS=()
|
||||
link "$R" # 1 the root itself
|
||||
link "$R/root" # 2 under the root
|
||||
link "$R/root/x (deleted)" # 3 a deleted directory under the root
|
||||
link "$R (deleted)" # 4 another directory: the root exists
|
||||
link "$W/ab/tmp.example" # 5 what [bc] would match as a pattern
|
||||
link "$W/ab/tmp.example/root" # 6 the same, deeper
|
||||
link "$W/a[bc]/tmp.example2" # 7 a sibling sharing a prefix
|
||||
link "$W/a[bc]/tmp.exampl" # 8 a shorter sibling
|
||||
link / # 9
|
||||
link /opt/unrelated/work # 10
|
||||
link "$R/line"$'\n'"break" # 11 a newline, under the root
|
||||
link "$R/a"$'\t'"b" # 12 a tab, under the root
|
||||
link "$R"$'\n' # 13 the root plus a newline: another directory
|
||||
n=$((n+1)); mkdir -p -- "$W/links/$n"; : >"$W/links/$n/cwd"; LINKS+=("$W/links/$n/cwd") # 14 no target
|
||||
link "$W/s*t/tmp.example/root" # 15
|
||||
link "$W/sXt/tmp.example/root" # 16 what * would match as a pattern
|
||||
link "$W/q?x/tmp.example/root" # 17
|
||||
link "$W/qZx/tmp.example/root" # 18 what ? would match as a pattern
|
||||
same 'match: root with [bc] selects only its own tree, deleted dirs inside included' '1 2 3 11 12' "$(match "$R")"
|
||||
same 'match: root with *' '15' "$(match "$W/s*t/tmp.example")"
|
||||
same 'match: root with ?' '17' "$(match "$W/q?x/tmp.example")"
|
||||
same 'match: the ab sibling selects only its own tree' '5 6' "$(match "$W/ab/tmp.example")"
|
||||
same 'match: an empty root selects nothing' '' "$(match '')"
|
||||
same 'match: / as root selects nothing' '' "$(match /)"
|
||||
same 'match: a relative root selects nothing' '' "$(match 'tmp.example')"
|
||||
same 'match: no links reads nothing' '' "$(cwd_links_in "$R" | tr '\0' ' ')"
|
||||
|
||||
# The pid of a link: only /proc/<digits>/cwd.
|
||||
same 'pid: /proc/123/cwd' '123' "$(link_pid /proc/123/cwd)"
|
||||
expect 'pid: /proc/self/cwd' 1 link_pid /proc/self/cwd
|
||||
expect 'pid: /proc//cwd' 1 link_pid /proc//cwd
|
||||
expect 'pid: /proc/12/task/13/cwd' 1 link_pid /proc/12/task/13/cwd
|
||||
expect 'pid: a dummy link' 1 link_pid "$W/links/1/cwd"
|
||||
|
||||
# The socket shape and byte length.
|
||||
same 'socket: shape' "$R/root/data/launches/acme/r000000000000/tools.sock" "$(socket_shape "$R")"
|
||||
same 'socket: bytes, not characters' 5 "$(LC_ALL=C.UTF-8 byte_length $'/a/\xc3\xa9')"
|
||||
|
||||
links=(/proc/[0-9]*/cwd)
|
||||
start=${EPOCHREALTIME/[.,]/}
|
||||
hits=$(cwd_links_in "$W/keep" "${links[@]}" | tr -cd '\0' | wc -c)
|
||||
end=${EPOCHREALTIME/[.,]/}
|
||||
same "speed: no process under an empty root among ${#links[@]} cwd links" 0 "$hits"
|
||||
echo "time: one read of ${#links[@]} cwd links took $(( (end - start) / 1000 )) ms"
|
||||
printf '\nsandbox-check: %s passed, %s failed\n' "$PASS" "$FAIL"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
@@ -0,0 +1,113 @@
|
||||
# shellcheck shell=bash
|
||||
# C2-14 (#1728, row 227 R3): the sandbox root of scripts/test-case2.sh and the cleanup that hangs
|
||||
# off it. test-case2.sh sources this file before it allocates anything. The kill sweep and the
|
||||
# removal each check the root themselves, so a caller that runs them early, late or with an empty
|
||||
# value gets a refusal, never a sweep of the host. scripts/case2/sandbox-check.sh exercises the
|
||||
# guard and the match against dummy symlinks.
|
||||
|
||||
# The tools socket is the longest Unix socket path a run builds. packages/seat/src/session.mjs
|
||||
# newRun puts it at <data root>/launches/<business>/r<12 hex digits>/tools.sock and refuses a path
|
||||
# over SOCKET_LIMIT bytes. The driver's data root is <sandbox>/root/data (fixture() in
|
||||
# packages/cli/tests/helpers.mjs, given <sandbox>/root by test-case2.sh) and its business is acme
|
||||
# (scripts/case2/e2e.mjs). socket_shape builds that path on a real sandbox root with a run id of the
|
||||
# same length, so its byte length is the length the run will have.
|
||||
SOCKET_LIMIT=107
|
||||
socket_shape() { printf '%s/root/data/launches/acme/r%s/tools.sock' "$1" 000000000000; }
|
||||
|
||||
# The length of $1 in bytes, not characters, whatever the caller's locale.
|
||||
byte_length() {
|
||||
local LC_ALL=C
|
||||
printf '%s' "${#1}"
|
||||
}
|
||||
|
||||
# The device:inode of $1, the identity recorded when mktemp creates the sandbox.
|
||||
sandbox_id() { stat -c '%d:%i' -- "$1" 2>/dev/null; }
|
||||
|
||||
# sandbox_ok ROOT ID: ROOT is the sandbox this run created. It is non-empty, absolute and not /;
|
||||
# it is its own physical path (no symlink in it, no trailing slash, no . or .. part), which is the
|
||||
# form a /proc/<pid>/cwd link reads; it is a directory this user owns; and its device:inode is ID.
|
||||
sandbox_ok() {
|
||||
local root=${1-} id=${2-} physical
|
||||
[ -n "$root" ] && [ -n "$id" ] || return 1
|
||||
case "$root" in /?*) ;; *) return 1 ;; esac
|
||||
[ -d "$root" ] && [ ! -L "$root" ] && [ -O "$root" ] || return 1
|
||||
physical=$(cd -P -- "$root" 2>/dev/null && pwd -P) || return 1
|
||||
[ "$physical" = "$root" ] || return 1
|
||||
[ "$(sandbox_id "$root")" = "$id" ]
|
||||
}
|
||||
|
||||
# dir_empty DIR: DIR holds no entry, dot entries included.
|
||||
dir_empty() (
|
||||
shopt -s nullglob dotglob
|
||||
set -- "$1"/*
|
||||
[ "$#" -eq 0 ]
|
||||
)
|
||||
|
||||
# cwd_in ROOT TARGET: TARGET, a cwd link target, is ROOT or lies under ROOT/. The two are compared
|
||||
# as strings. Quoted text in a case pattern matches only itself, so *, ? and [ in ROOT are literal;
|
||||
# the only pattern character is the /* written here. A cwd in a deleted directory reads
|
||||
# "<path> (deleted)". One under ROOT/ still matches. "ROOT (deleted)" does not: the sweep runs only
|
||||
# after sandbox_ok has shown ROOT exists, so that target is another directory, one whose name ends
|
||||
# in " (deleted)".
|
||||
cwd_in() {
|
||||
case "${1-}" in /?*) ;; *) return 1 ;; esac
|
||||
case "${2-}" in "$1" | "$1"/*) return 0 ;; esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# cwd_links_in ROOT LINK...: print, each followed by NUL, every LINK whose target cwd_in puts in
|
||||
# ROOT. One find reads every target (%l), so the cost is one process however many the host runs. A
|
||||
# link this user may not read, another user's process, gives an empty target, which never matches.
|
||||
# Each record is LINK, a tab, then TARGET; LINK has no tab and TARGET may hold anything but NUL.
|
||||
cwd_links_in() {
|
||||
local root=$1 record
|
||||
shift
|
||||
[ "$#" -gt 0 ] || return 0
|
||||
while IFS= read -r -d '' record; do
|
||||
if cwd_in "$root" "${record#*$'\t'}"; then printf '%s\0' "${record%%$'\t'*}"; fi
|
||||
done < <(command find "$@" -maxdepth 0 -printf '%p\t%l\0' 2>/dev/null)
|
||||
}
|
||||
|
||||
# link_pid LINK: the pid in a link of the form /proc/<digits>/cwd, or failure for anything else.
|
||||
link_pid() {
|
||||
local pid=${1#/proc/}
|
||||
[ "$pid" != "$1" ] || return 1
|
||||
pid=${pid%/cwd}
|
||||
case "$pid" in '' | *[!0-9]*) return 1 ;; esac
|
||||
printf '%s' "$pid"
|
||||
}
|
||||
|
||||
# sweep_sandbox ROOT ID: SIGKILL every process whose cwd is in ROOT. It refuses, before it reads any
|
||||
# link, unless sandbox_ok ROOT ID. Up to three passes, so a child forked during one pass is caught
|
||||
# by the next. Prints a note when it killed something; returns 1 when it refused.
|
||||
sweep_sandbox() {
|
||||
local root=${1-} id=${2-} link pid pass hit
|
||||
local -A killed=()
|
||||
if ! sandbox_ok "$root" "$id"; then
|
||||
printf 'test-case2: kill sweep refused: %q is not the sandbox this run created\n' "$root" >&2
|
||||
return 1
|
||||
fi
|
||||
for pass in 1 2 3; do
|
||||
hit=0
|
||||
while IFS= read -r -d '' link; do
|
||||
pid=$(link_pid "$link") || continue
|
||||
if kill -KILL "$pid" 2>/dev/null; then hit=1; killed[$pid]=1; fi
|
||||
done < <(cwd_links_in "$root" /proc/[0-9]*/cwd)
|
||||
[ "$hit" = 1 ] || break
|
||||
done
|
||||
if [ "${#killed[@]}" -gt 0 ]; then
|
||||
echo "note: ${#killed[@]} process(es) under the sandbox outlived the driver; sent SIGKILL"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# remove_sandbox ROOT ID: remove ROOT, refusing unless sandbox_ok ROOT ID. --one-file-system keeps
|
||||
# rm out of any mount left under it.
|
||||
remove_sandbox() {
|
||||
local root=${1-} id=${2-}
|
||||
if ! sandbox_ok "$root" "$id"; then
|
||||
printf 'test-case2: removal refused: %q is not the sandbox this run created\n' "$root" >&2
|
||||
return 1
|
||||
fi
|
||||
rm -rf --one-file-system -- "$root"
|
||||
}
|
||||
Executable
+128
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env bash
|
||||
# C2-14 (#1728, row 227): one acceptance run on a scratch root, no skipped steps.
|
||||
# Entry point for the real launcher/runner, C2-06 scripted adapter, fake TLS Gitea and Console DOM.
|
||||
# Stands alone: the landing gate runs it as `bash scripts/test-case2.sh` with nothing else set up.
|
||||
# The only human stand-in is bindHuman in-process; a development agent cannot prove human CLI ancestry.
|
||||
set -uo pipefail
|
||||
fatal() { printf 'test-case2: %s\n' "$*" >&2; exit 2; }
|
||||
cd "$(dirname "$0")/.." || fatal 'cannot enter the repository root; nothing was started'
|
||||
. scripts/case2/sandbox.sh || fatal 'cannot load scripts/case2/sandbox.sh; nothing was started'
|
||||
# The sandbox root. Every later path, the kill sweep and the removal hang off it, so nothing is
|
||||
# derived from it, no trap is set and nothing starts until it is this run's own directory: mktemp
|
||||
# exited 0 under an absolute TMPDIR, and the result is absolute, not a symlink, an empty directory
|
||||
# this user owns. It is kept as its physical path, the form a /proc/<pid>/cwd link reads, and its
|
||||
# device:inode goes into SANDBOX_ID. sweep_sandbox and remove_sandbox check both again themselves.
|
||||
case "${TMPDIR:-/tmp}" in
|
||||
/*) ;;
|
||||
*) fatal "TMPDIR is not an absolute path: $TMPDIR; nothing was started" ;;
|
||||
esac
|
||||
made="$(mktemp -d)" || fatal "mktemp -d failed under TMPDIR=${TMPDIR:-/tmp}; nothing was started"
|
||||
case "$made" in
|
||||
/?*) ;;
|
||||
*) fatal "mktemp -d gave '$made', not an absolute path; nothing was started" ;;
|
||||
esac
|
||||
[ -d "$made" ] && [ ! -L "$made" ] && [ -O "$made" ] && dir_empty "$made" \
|
||||
|| fatal "mktemp -d gave $made, not a new empty directory of this user; nothing was started"
|
||||
SANDBOX="$(cd -P -- "$made" && pwd -P)" && SANDBOX_ID="$(sandbox_id "$SANDBOX")" \
|
||||
&& sandbox_ok "$SANDBOX" "$SANDBOX_ID" \
|
||||
|| fatal "cannot resolve the sandbox $made; nothing was started"
|
||||
readonly SANDBOX SANDBOX_ID
|
||||
DRIVER_PID=''
|
||||
alive() {
|
||||
local stat
|
||||
read -r stat 2>/dev/null <"/proc/$1/stat" || return 1
|
||||
stat="${stat##*) }"
|
||||
[ "${stat%% *}" != Z ]
|
||||
}
|
||||
# The driver leads its own process group (setsid below). On a signal it gets SIGTERM and 3 s by the
|
||||
# clock to close, inside the landing gate's 5 s grace, then its group gets SIGKILL. The launcher
|
||||
# starts each bwrap session in a group of its own, with its working directory in the sandbox; any
|
||||
# such process still alive then gets SIGKILL too, before the sandbox goes. sweep_sandbox reads every
|
||||
# cwd link with one find and compares targets as strings, so the sweep stays in milliseconds however
|
||||
# many processes the host runs.
|
||||
now_us() { echo "${EPOCHREALTIME/[.,]/}"; }
|
||||
stop_driver() {
|
||||
local deadline
|
||||
if [ -n "$DRIVER_PID" ]; then
|
||||
kill -TERM "$DRIVER_PID" 2>/dev/null
|
||||
deadline=$(( $(now_us) + 3000000 ))
|
||||
while alive "$DRIVER_PID" && [ "$(now_us)" -lt "$deadline" ]; do sleep 0.1; done
|
||||
kill -KILL -- "-$DRIVER_PID" 2>/dev/null
|
||||
wait "$DRIVER_PID" 2>/dev/null
|
||||
DRIVER_PID=''
|
||||
fi
|
||||
sweep_sandbox "$SANDBOX" "$SANDBOX_ID"
|
||||
}
|
||||
# A second INT or TERM during cleanup is ignored, so it cannot cut the sweep short; the gate's
|
||||
# SIGKILL after its grace still ends everything. A refused sweep or removal turns a pass red.
|
||||
cleanup() {
|
||||
local result=$?
|
||||
trap '' INT TERM
|
||||
trap - EXIT
|
||||
stop_driver || result=$(( result ? result : 1 ))
|
||||
remove_sandbox "$SANDBOX" "$SANDBOX_ID" || result=$(( result ? result : 1 ))
|
||||
exit "$result"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
# The tools socket is the longest socket path the run builds (scripts/case2/sandbox.sh). Refuse a
|
||||
# TMPDIR that would push it past the product's limit before anything starts.
|
||||
SOCKET_BYTES=$(byte_length "$(socket_shape "$SANDBOX")")
|
||||
if [ "$SOCKET_BYTES" -gt "$SOCKET_LIMIT" ]; then
|
||||
tmpdir_bytes=$(byte_length "${TMPDIR:-/tmp}")
|
||||
fatal "TMPDIR is too long: the run's tools socket path would be $SOCKET_BYTES bytes, over the" \
|
||||
"$SOCKET_LIMIT-byte limit in packages/seat/src/session.mjs; TMPDIR is $tmpdir_bytes bytes," \
|
||||
"at most $(( tmpdir_bytes - SOCKET_BYTES + SOCKET_LIMIT )) fit; nothing was started"
|
||||
fi
|
||||
CASE2_HOME="$SANDBOX/home"
|
||||
CASE2_TMP="$SANDBOX/t"
|
||||
CASE2_CONFIG="$SANDBOX/root/config/config.json"
|
||||
mkdir -p -- "$CASE2_HOME" "$CASE2_TMP" || fatal "cannot create $CASE2_HOME and $CASE2_TMP"
|
||||
# The driver runs from an explicit environment: PATH and LANG, a sandbox HOME, XDG dirs and TMPDIR,
|
||||
# and the variables it needs. Nothing else from the caller, a credential variable included, reaches
|
||||
# the driver or the launcher it starts. TMPDIR in the sandbox takes the Chromium profile and the
|
||||
# plug-in tree with it when the sandbox goes.
|
||||
CLEAN_ENV=(env -i PATH="$PATH" LANG=C.UTF-8 HOME="$CASE2_HOME"
|
||||
XDG_CONFIG_HOME="$CASE2_HOME/.config" XDG_CACHE_HOME="$CASE2_HOME/.cache"
|
||||
XDG_DATA_HOME="$CASE2_HOME/.local/share" XDG_STATE_HOME="$CASE2_HOME/.local/state"
|
||||
TMPDIR="$CASE2_TMP" MOSAIC_CONFIG="$CASE2_CONFIG" GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1)
|
||||
PASS=0 FAIL=0
|
||||
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
|
||||
C_OK=$'\033[0;32m'; C_FAIL=$'\033[0;31m'; C_RESET=$'\033[0m'
|
||||
else
|
||||
C_OK=''; C_FAIL=''; C_RESET=''
|
||||
fi
|
||||
check() {
|
||||
if [ "$2" = 0 ]; then PASS=$((PASS+1)); echo "${C_OK}OK${C_RESET} $1"
|
||||
else FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} $1"; fi
|
||||
}
|
||||
DRIVER=scripts/case2/e2e.mjs
|
||||
STEPS='S1a S1b S2a S2b S2c S2d S3a S3b S3c S4 S5a S5b S6 S7 S8 S9 S10'
|
||||
"${CLEAN_ENV[@]}" node --check "$DRIVER" >/dev/null 2>&1
|
||||
check "syntax: $DRIVER" "$?"
|
||||
OUT="$SANDBOX/steps.txt" ERR="$SANDBOX/stderr.txt"
|
||||
: >"$OUT" && : >"$ERR" || fatal "cannot create the driver output files in $SANDBOX"
|
||||
"${CLEAN_ENV[@]}" setsid node "$DRIVER" "$SANDBOX/root" >"$OUT" 2>"$ERR" &
|
||||
DRIVER_PID=$!
|
||||
wait "$DRIVER_PID"
|
||||
RC=$?
|
||||
DRIVER_PID=''
|
||||
check "driver ran to the end (exit $RC)" "$RC"
|
||||
if [ "$RC" -ne 0 ]; then grep '^e2e: ' "$ERR" | head -5; fi
|
||||
seen=''
|
||||
while IFS= read -r line; do
|
||||
case "$line" in
|
||||
'ok '*) id="${line#ok }"; id="${id%% *}"; check "${line#ok }" 0 ;;
|
||||
'not ok '*) id="${line#not ok }"; id="${id%% *}"; check "${line#not ok }" 1 ;;
|
||||
*) id=''; check 'driver line not understood (a skip cannot pass acceptance)' 1 ;;
|
||||
esac
|
||||
[ -n "$id" ] && seen="$seen $id"
|
||||
done <"$OUT"
|
||||
[ "${seen# }" = "$STEPS" ]
|
||||
check 'every acceptance step ran once, in order, with nothing skipped' "$?"
|
||||
# The values come from scripts/case2/fixture-credentials.mjs and are never printed.
|
||||
"${CLEAN_ENV[@]}" node scripts/case2/leak-check.mjs "$OUT" "$ERR"
|
||||
check 'fixture credentials are in no driver output' "$?"
|
||||
printf '\nselftest: %s passed, %s failed, 0 skipped\n' "$PASS" "$FAIL"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
Reference in new issue
Block a user