166 lines
11 KiB
JavaScript
166 lines
11 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, readdirSync, symlinkSync, truncateSync, writeFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { fixture, tmp } from '../../cli/tests/helpers.mjs';
|
|
import { initializeControl, transition } from '../src/state.mjs';
|
|
const TASK = new URL('../../../scripts/mosaic-task.mjs', import.meta.url).pathname;
|
|
function setup(t, stop = false) {
|
|
const root = tmp(t), f = fixture(root), bin = join(root, 'bin'); mkdirSync(bin);
|
|
const source = new URL('../src/state.mjs', import.meta.url).href;
|
|
const runtimes = new URL('../src/runtimes.mjs', import.meta.url).href;
|
|
writeFileSync(join(bin, 'docker'), `#!${process.execPath}
|
|
(async()=>{const args=process.argv.slice(2),fs=require('node:fs');fs.writeFileSync(${JSON.stringify(join(root, 'called'))},JSON.stringify(args));fs.writeFileSync(${JSON.stringify(join(root, 'docker-data-root-env'))},process.env.MOSAIC_DATA_ROOT??'');
|
|
${stop ? `const {transition}=await import(${JSON.stringify(source)});const {updateRuntime}=await import(${JSON.stringify(runtimes)});const dataRoot=${JSON.stringify(f.dataRoot)};const s=transition(dataRoot,{business:'acme',human:'jason',halted:true});const tag=args.find(x=>x.startsWith('mosaic.runtime='));if(tag)updateRuntime(dataRoot,tag.slice(15),{haltGeneration:s.generation});process.exit(137);` : `console.log('normal response');`}
|
|
})().catch(e=>{console.error(e.message);process.exit(1)});\n`, { mode: 0o755 });
|
|
f.env.PATH = bin + ':' + f.env.PATH;
|
|
const task = join(root, 'task.json'); writeFileSync(task, JSON.stringify({ taskVersion: 1, id: 'one', prompt: 'request' }));
|
|
const run = args => spawnSync(process.execPath, [TASK, ...args], { env: f.env, encoding: 'utf8', timeout: 10000 });
|
|
return { root, f, task, run };
|
|
}
|
|
test('task halt keeps the true Docker exit status, writes kill-switch once and leaves snapshots unchanged', t => {
|
|
const f = setup(t, true), result = f.run(['run', f.task]);
|
|
assert.equal(result.status, 1, result.stderr);
|
|
const runs = join(f.f.dataRoot, 'runs'), [id] = readdirSync(runs);
|
|
const file = join(runs, id, 'result.json'), bytes = readFileSync(file), record = JSON.parse(bytes);
|
|
assert.equal(record.reason, 'kill-switch'); assert.match(record.haltGeneration, /^[a-f0-9-]{36}$/);
|
|
assert.equal(record.exitCode, 137); assert.equal(record.signal, null);
|
|
assert.deepEqual(readFileSync(join(runs, id, 'task.json')), readFileSync(f.task));
|
|
const retry = f.run(['retry', id]); assert.equal(retry.status, 3, retry.stderr);
|
|
assert.deepEqual(readFileSync(file), bytes); assert.deepEqual(readdirSync(runs), [id]);
|
|
});
|
|
test('halt refuses direct task launch before Docker or a run record; clear allows a fresh normal result', t => {
|
|
const f = setup(t), actor = { business: 'acme', human: 'jason' };
|
|
initializeControl(f.f.dataRoot); const halted = transition(f.f.dataRoot, { ...actor, halted: true });
|
|
const result = f.run(['run', f.task]); assert.equal(result.status, 3, result.stderr);
|
|
assert.equal(existsSync(join(f.root, 'called')), false);
|
|
assert.equal(existsSync(join(f.f.dataRoot, 'runs')), false);
|
|
transition(f.f.dataRoot, { ...actor, halted: false, generation: halted.generation });
|
|
const normal = f.run(['run', f.task]); assert.equal(normal.status, 0, normal.stderr);
|
|
const [id] = readdirSync(join(f.f.dataRoot, 'runs'));
|
|
const record = JSON.parse(readFileSync(join(f.f.dataRoot, 'runs', id, 'result.json')));
|
|
assert.equal(record.reason, null); assert.equal(record.status, 'succeeded');
|
|
assert.equal(record.response, 'normal response'); assert.equal('haltGeneration' in record, false);
|
|
});
|
|
|
|
test('task launch command exposes no data root, sibling evidence, sibling workspace/session or broker socket', t => {
|
|
const f = setup(t);
|
|
mkdirSync(join(f.f.dataRoot, 'user'), { recursive: true });
|
|
writeFileSync(join(f.f.dataRoot, 'user', 'USER.md'), '# User\n');
|
|
mkdirSync(join(f.f.dataRoot, 'runs', 'r-sibling'), { recursive: true });
|
|
writeFileSync(join(f.f.dataRoot, 'runs', 'r-sibling', 'result.json'), '{}\n');
|
|
mkdirSync(join(f.f.dataRoot, 'workspaces', 'sibling'), { recursive: true });
|
|
mkdirSync(join(f.f.dataRoot, 'sessions', 'sibling'), { recursive: true });
|
|
mkdirSync(join(f.f.dataRoot, 'sessions', 'base'), { recursive: true });
|
|
const forkSource = join(f.f.dataRoot, 'sessions', 'base', 'turn.jsonl');
|
|
const forkBytes = Buffer.from('{"type":"message","text":"remember me"}\n');
|
|
writeFileSync(forkSource, forkBytes);
|
|
const outside = join(f.root, 'outside.txt'); writeFileSync(outside, 'must not replace the fork snapshot\n');
|
|
mkdirSync(join(f.f.dataRoot, 'bus'), { recursive: true });
|
|
writeFileSync(join(f.f.dataRoot, 'bus', 'broker.sock'), 'not reachable\n');
|
|
const mission = join(f.root, 'mission.json');
|
|
writeFileSync(mission, JSON.stringify({ missionVersion: 1, id: 'm-one', objective: 'bounded', directives: ['stay bounded'] }));
|
|
writeFileSync(f.task, JSON.stringify({
|
|
taskVersion: 1, id: 'one', prompt: 'request', mission,
|
|
workspace: 'selected', session: 'child', sessionForkFrom: 'base',
|
|
}));
|
|
writeFileSync(join(f.root, 'bin', 'docker'), `#!${process.execPath}
|
|
const fs=require('node:fs'),args=process.argv.slice(2);
|
|
fs.renameSync(${JSON.stringify(forkSource)},${JSON.stringify(forkSource + '.original')});
|
|
fs.symlinkSync(${JSON.stringify(outside)},${JSON.stringify(forkSource)});
|
|
fs.writeFileSync(${JSON.stringify(join(f.root, 'called'))},JSON.stringify(args));
|
|
fs.writeFileSync(${JSON.stringify(join(f.root, 'docker-data-root-env'))},process.env.MOSAIC_DATA_ROOT??'');
|
|
console.log('normal response');
|
|
`, { mode: 0o755 });
|
|
const result = f.run(['run', f.task]);
|
|
assert.equal(result.status, 0, result.stderr);
|
|
assert.equal(readFileSync(join(f.root, 'docker-data-root-env'), 'utf8'), '');
|
|
const args = JSON.parse(readFileSync(join(f.root, 'called'), 'utf8'));
|
|
const text = JSON.stringify(args);
|
|
assert.doesNotMatch(text, new RegExp(JSON.stringify(f.f.dataRoot + ':/var/lib/mosaic').slice(1, -1)));
|
|
for (const hidden of [join(f.f.dataRoot, 'runs', 'r-sibling'), join(f.f.dataRoot, 'workspaces', 'sibling'),
|
|
join(f.f.dataRoot, 'sessions', 'sibling'), join(f.f.dataRoot, 'sessions', 'base'),
|
|
join(f.f.dataRoot, 'bus', 'broker.sock'), outside]) assert.equal(text.includes(hidden), false, hidden);
|
|
const valuesAfter = flag => args.flatMap((v, i) => v === flag ? [args[i + 1]] : []);
|
|
const volumes = valuesAfter('--volume');
|
|
assert.ok(volumes.includes(`${join(f.f.dataRoot, 'control')}:/var/lib/mosaic/control:ro`));
|
|
assert.ok(volumes.some(v => /\/runs\/r-[^/]+\/task\.json:\/var\/lib\/mosaic\/input\/task\.json:ro$/.test(v)));
|
|
assert.ok(volumes.some(v => /\/runs\/r-[^/]+\/mission\.json:\/var\/lib\/mosaic\/input\/mission\.json:ro$/.test(v)));
|
|
assert.ok(volumes.includes(`${join(f.f.dataRoot, 'workspaces', 'selected')}:/workspace:rw`));
|
|
assert.ok(volumes.includes(`${join(f.f.dataRoot, 'sessions', 'child')}:/var/lib/mosaic/session:rw`));
|
|
const runId = readdirSync(join(f.f.dataRoot, 'runs')).find(name => name !== 'r-sibling');
|
|
const forkSnapshot = join(f.f.dataRoot, 'runs', runId, 'session-fork.jsonl');
|
|
assert.ok(volumes.includes(`${forkSnapshot}:/var/lib/mosaic/input/session-fork.jsonl:ro`));
|
|
assert.deepEqual(readFileSync(forkSnapshot), forkBytes);
|
|
assert.equal(lstatSync(forkSource).isSymbolicLink(), true);
|
|
assert.equal(readlinkSync(forkSource), outside);
|
|
const passedEnv = valuesAfter('-e');
|
|
for (const value of [
|
|
'MOSAIC_TASK_FILE=/var/lib/mosaic/input/task.json',
|
|
'MOSAIC_MISSION_FILE=/var/lib/mosaic/input/mission.json',
|
|
'MOSAIC_WORKSPACE=/workspace',
|
|
'MOSAIC_SESSION_DIR=/var/lib/mosaic/session',
|
|
'MOSAIC_SESSION_FORK=/var/lib/mosaic/input/session-fork.jsonl',
|
|
'MOSAIC_SYSTEM_PROMPT_FILE=/var/lib/mosaic/private/system-prompt.md',
|
|
]) assert.ok(passedEnv.includes(value), value);
|
|
});
|
|
|
|
test('newest session fork symlink refuses before Docker without copying its target', t => {
|
|
const f = setup(t), sourceDir = join(f.f.dataRoot, 'sessions', 'base');
|
|
mkdirSync(sourceDir, { recursive: true });
|
|
const outside = join(f.root, 'outside.txt'); writeFileSync(outside, 'host target bytes\n');
|
|
writeFileSync(join(sourceDir, 'a-older.jsonl'), '{"safe":"older"}\n');
|
|
symlinkSync(outside, join(sourceDir, 'z-newest.jsonl'));
|
|
writeFileSync(f.task, JSON.stringify({
|
|
taskVersion: 1, id: 'one', prompt: 'request', session: 'child', sessionForkFrom: 'base',
|
|
}));
|
|
const result = f.run(['run', f.task]);
|
|
assert.equal(result.status, 4, result.stderr);
|
|
assert.match(result.stderr, /session fork source must be a regular, non-symbolic-link file/);
|
|
assert.equal(existsSync(join(f.root, 'called')), false);
|
|
const [runId] = readdirSync(join(f.f.dataRoot, 'runs'));
|
|
assert.deepEqual(readdirSync(join(f.f.dataRoot, 'runs', runId)), ['task.json']);
|
|
assert.equal(readFileSync(join(f.f.dataRoot, 'runs', runId, 'task.json'), 'utf8').includes('host target bytes'), false);
|
|
});
|
|
|
|
test('oversized session fork refuses before Docker or snapshot publication', t => {
|
|
const f = setup(t), sourceDir = join(f.f.dataRoot, 'sessions', 'base');
|
|
mkdirSync(sourceDir, { recursive: true });
|
|
const source = join(sourceDir, 'turn.jsonl');
|
|
writeFileSync(source, '');
|
|
truncateSync(source, 16 * 1024 * 1024 + 1);
|
|
writeFileSync(f.task, JSON.stringify({
|
|
taskVersion: 1, id: 'one', prompt: 'request', session: 'child', sessionForkFrom: 'base',
|
|
}));
|
|
const result = f.run(['run', f.task]);
|
|
assert.equal(result.status, 4, result.stderr);
|
|
assert.match(result.stderr, /session fork source exceeds 16777216 bytes/);
|
|
assert.equal(existsSync(join(f.root, 'called')), false);
|
|
const [runId] = readdirSync(join(f.f.dataRoot, 'runs'));
|
|
assert.equal(existsSync(join(f.f.dataRoot, 'runs', runId, 'session-fork.jsonl')), false);
|
|
});
|
|
|
|
for (const [script, args] of [['hello.sh', []], ['verify.sh', []], ['agent.sh', ['fixture']]]) {
|
|
test(`halt refuses ${script} before Docker, credentials or release alignment`, t => {
|
|
const f = setup(t); initializeControl(f.f.dataRoot);
|
|
transition(f.f.dataRoot, { business: 'acme', human: 'jason', halted: true });
|
|
const r = spawnSync('bash', [new URL('../../../scripts/' + script, import.meta.url).pathname, ...args],
|
|
{ env: f.f.env, encoding: 'utf8', timeout: 5000 });
|
|
assert.equal(r.status, 3, r.stderr); assert.match(r.stderr, /processing-halted/);
|
|
assert.equal(existsSync(join(f.root, 'called')), false);
|
|
});
|
|
}
|
|
|
|
for (const starter of ['task', 'container-cli']) test(`a nonzero Docker client exit retains its unresolved runtime receipt (${starter})`, t => {
|
|
const f = setup(t);
|
|
writeFileSync(join(f.root, 'bin', 'docker'), `#!${process.execPath}\nprocess.exit(137);\n`, { mode: 0o755 });
|
|
const result = starter === 'task' ? f.run(['run', f.task]) : spawnSync(process.execPath,
|
|
[new URL('../src/container-cli.mjs', import.meta.url).pathname, 'request'], { env: f.f.env, encoding: 'utf8', timeout: 10000 });
|
|
assert.equal(result.status, starter === 'task' ? 1 : 137, result.stderr);
|
|
const files = readdirSync(join(f.f.dataRoot, 'control', 'runtimes'));
|
|
assert.equal(files.length, 1, 'a failed Docker client cannot prove the container stopped');
|
|
const runtime = JSON.parse(readFileSync(join(f.f.dataRoot, 'control', 'runtimes', files[0])));
|
|
assert.notEqual(runtime.phase, 'ended');
|
|
});
|