// `mosaic run evidence ORG --thread REF [--verify] [--json]`: the run records a // thread's launches left behind, read-only (r4 ยง7). A bus launch writes no // result.json or snapshot, so the evidence is what it does write once: // result -> /session.json (wx 0600) plus the launch and end records in // the organization's launch log // snapshot -> /bundle/manifest.json (wx 0600), every `files` digest recomputed // stderr -> /turns/NNNN.stderr (wx 0600), hashed // A launch has no id of its own; the launch log line (file, line, sha256) names it. // Exit: 0 found (and verified with --verify), 1 a check failed, 2 nothing found or // invalid, 3 refused, 4 usage. import { createHash } from 'node:crypto'; import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readdirSync } from 'node:fs'; import { dirname, join, relative, resolve, sep } from 'node:path'; import { configDir } from '../../organization/src/organization-config.mjs'; import { resolveOrg, OrgError } from '../../organization/src/org-registry.mjs'; import { launchLogFile, runsDir } from '../../seat/src/session.mjs'; import * as cliConfig from './config.mjs'; import { DEV_OWNER, devOwnerInstance } from './dev-local-owner.mjs'; import { CliError } from './errors.mjs'; import { parseArgs, resolveThread } from './thread.mjs'; export const RUN_USAGE = 'mosaic run evidence ORG --thread REF [--verify] [--json] [--tester]'; const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const RUN_ID = /^r[0-9a-f]{12}$/; const STDERR = /^[0-9]{4}\.stderr$/; const sha256 = buf => createHash('sha256').update(buf).digest('hex'); const usage = why => new CliError(`${why}\nusage: ${RUN_USAGE}`, 4); // A regular file of this user, opened without following a link. Returns its // bytes, or throws {problem}. function readOwned(path) { let fd; try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); } catch (e) { throw Object.assign(new Error(e.code === 'ENOENT' ? 'missing' : `cannot open (${e.code})`), { problem: true }); } try { const st = fstatSync(fd); if (!st.isFile()) throw Object.assign(new Error('not a regular file'), { problem: true }); if (st.uid !== process.getuid()) throw Object.assign(new Error('not owned by this user'), { problem: true }); return { bytes: readFileSync(fd), mode: st.mode & 0o777 }; } finally { closeSync(fd); } } // One evidence file: its path, sha256 and any problem. `expect` is the recorded digest. function fileEvidence(path, { expect = null, privateMode = true } = {}) { const out = { path, sha256: null, ok: false }; try { const { bytes, mode } = readOwned(path); out.sha256 = sha256(bytes); if (privateMode && mode !== 0o600) out.problem = `mode ${mode.toString(8)}, expected 600`; else if (expect !== null && expect !== out.sha256) out.problem = 'digest differs from the manifest'; else out.ok = true; return { ...out, bytes }; } catch (e) { if (!e.problem) throw e; return { ...out, problem: e.message }; } } const strip = ({ bytes, ...rest }) => rest; const inside = (root, path) => { const r = relative(root, path); return r !== '' && !r.startsWith('..') && !r.startsWith(sep) && r !== '..'; }; // The launch log's records for one thread, with their line numbers and digests. export function threadRecords(file, thread) { const log = fileEvidence(file); if (!log.ok && log.problem === 'missing') return { file, missing: true, launches: [], ends: [], malformed: 0 }; if (!log.ok) throw new CliError(`run-evidence-refused: launch log: ${log.problem}`, 3); const launches = [], ends = []; let malformed = 0; log.bytes.toString('utf8').split('\n').forEach((line, i) => { if (!line) return; let rec; try { rec = JSON.parse(line); } catch { malformed++; return; } if (rec?.thread !== thread) return; const at = { file, line: i + 1, sha256: sha256(line), record: rec }; if (rec.event === 'launch') launches.push(at); else if (rec.event === 'end') ends.push(at); }); return { file, missing: false, launches, ends, malformed }; } // Everything one run left, checked against its own records. `verify` recomputes digests. export function runEvidence({ runs, thread, launch, ends, verify }) { const run = launch.record.run; const problems = []; const out = { run, launch: { file: launch.file, line: launch.line, sha256: launch.sha256, at: launch.record.at ?? null, session: launch.record.session ?? null, model: launch.record.model ?? null, harness: launch.record.harness ?? null }, end: ends.filter(e => e.record.run === run).map(e => ({ file: e.file, line: e.line, sha256: e.sha256, at: e.record.at ?? null, reason: e.record.reason ?? null, exitCode: e.record.exitCode ?? null })) }; if (typeof run !== 'string' || !RUN_ID.test(run)) return { ...out, ok: false, problems: [`launch record names no valid run id (${JSON.stringify(run)})`] }; const runDir = join(runs, run); out.runDir = runDir; let st; try { st = lstatSync(runDir); } catch (e) { if (e.code !== 'ENOENT') throw e; } if (!st) return { ...out, ok: false, problems: ['run directory missing'] }; if (st.isSymbolicLink() || !st.isDirectory() || st.uid !== process.getuid()) return { ...out, ok: false, problems: ['run directory is not a directory of this user'] }; const session = fileEvidence(join(runDir, 'session.json')); if (session.ok) { let doc = null; try { doc = JSON.parse(session.bytes.toString('utf8')); } catch { session.ok = false; session.problem = 'not JSON'; } if (doc && (doc.thread !== thread || doc.run !== run)) { session.ok = false; session.problem = `names thread ${doc.thread} run ${doc.run}`; } } out.result = strip(session); if (!session.ok) problems.push(`session.json: ${session.problem}`); const bundleDir = join(runDir, 'bundle'); const manifestPath = join(bundleDir, 'manifest.json'); const manifest = fileEvidence(manifestPath); let doc = null; if (manifest.ok) { try { doc = JSON.parse(manifest.bytes.toString('utf8')); } catch { manifest.ok = false; manifest.problem = 'not JSON'; } if (doc && doc.run !== run) { manifest.ok = false; manifest.problem = `names run ${doc.run}`; } else if (launch.record.manifest && resolve(launch.record.manifest) !== manifestPath) { manifest.ok = false; manifest.problem = `launch record names ${launch.record.manifest}`; } } const files = []; if (doc && verify) { const recorded = doc.files && typeof doc.files === 'object' ? Object.entries(doc.files) : []; if (recorded.length === 0 && manifest.ok) { manifest.ok = false; manifest.problem = 'manifest records no file digests'; } for (const [name, digest] of recorded) { const path = resolve(bundleDir, name); if (!inside(runDir, path)) { files.push({ name, path, ok: false, problem: 'outside the run directory' }); continue; } files.push({ name, ...strip(fileEvidence(path, { expect: digest, privateMode: false })) }); } } out.snapshot = { ...strip(manifest), ...(verify ? { files } : {}) }; if (!manifest.ok) problems.push(`bundle/manifest.json: ${manifest.problem}`); for (const f of files) if (!f.ok) problems.push(`bundle file ${f.name}: ${f.problem}`); const turns = join(runDir, 'turns'); let names = []; try { names = readdirSync(turns).filter(n => STDERR.test(n)).sort(); } catch (e) { if (e.code !== 'ENOENT' && e.code !== 'ENOTDIR') throw e; } out.stderr = names.map(n => strip(fileEvidence(join(turns, n)))); if (out.stderr.length === 0) problems.push('no turns/*.stderr'); for (const s of out.stderr) if (!s.ok) problems.push(`${relative(runDir, s.path)}: ${s.problem}`); if (!verify) for (const s of [out.result, out.snapshot, ...out.stderr]) delete s.sha256; return { ...out, ok: problems.length === 0, problems }; } // The thread id for REF: a full id as given, else the bus's own resolution, else a // unique thread name of the caller's. async function threadId(ref, call) { if (UUID.test(ref)) return { id: ref.toLowerCase(), label: ref }; if (!call) throw new CliError(`thread ${JSON.stringify(ref)} needs the bus to resolve; pass the thread id`, 2); try { return await resolveThread(call, ref); } catch (e) { if (!(e instanceof CliError) || e.exitCode !== 2) throw e; const hits = (await call('thread.list')).filter(t => t.name === ref); if (hits.length === 1) return { id: hits[0].id, label: ref }; if (hits.length > 1) throw new CliError(`${hits.length} threads are named ${JSON.stringify(ref)}; use / or the id`, 2); throw e; } } export async function runCommand(rest, io, deps = {}) { const [sub, ...args] = rest; if (sub !== 'evidence') throw usage(sub ? `unknown run command ${sub}` : 'run needs a command'); const parsed = parseArgs(args, `usage: ${RUN_USAGE}`, { flags: ['--verify', '--json', '--tester'], values: ['--thread'] }); if (parsed.positional.length !== 1) throw usage('run evidence takes one organization'); if (!parsed.values['--thread']) throw usage('run evidence needs --thread'); const id = parsed.positional[0]; let instance; try { instance = devOwnerInstance(io.env); } catch (e) { throw Object.assign(new CliError(`run-evidence-instance-refused: ${e.message}`, 3), { code: 'run-evidence-instance-refused' }); } const system = deps.system ?? cliConfig.loadSystem({ env: io.env, instance }); const ctx = { configDir: configDir(io.env), dataRoot: system.dataRoot, instance, principal: DEV_OWNER }; let org; try { org = await resolveOrg(ctx, id); } catch (e) { if (e instanceof OrgError) throw Object.assign(new CliError(`${e.code}: ${e.message}`, e.code === 'org-unknown' ? 2 : 3), { code: e.code }); throw e; } // Until the per-organization paths land (PLAN C3, orgPaths in config.mjs), the // launcher writes runs and the launch log under the instance data root. const base = typeof cliConfig.orgPaths === 'function' ? org.root : system.dataRoot; const callFor = deps.callFor ? () => deps.callFor({ positional: [], flags: parsed.flags, values: { '--business': id } }) : null; const thread = await threadId(parsed.values['--thread'], UUID.test(parsed.values['--thread']) ? null : callFor?.()); const verify = parsed.flags.has('--verify'); const log = threadRecords(launchLogFile(base, id), thread.id); const runs = log.launches.map(launch => runEvidence({ runs: runsDir(base, id), thread: thread.id, launch, ends: log.ends, verify })); const verified = verify ? runs.length > 0 && runs.every(r => r.ok) : null; const report = { org: { id: org.id, root: org.root, state: org.state }, instance, base, launchLog: { file: log.file, missing: log.missing, malformed: log.malformed }, thread, runs, verified }; if (parsed.flags.has('--json')) io.stdout.write(`${JSON.stringify(report, null, 2)}\n`); else { const lines = [`org ${org.id} (${org.state}) at ${org.root}`, `thread ${thread.label}${thread.label !== thread.id ? ` (${thread.id})` : ''}`, `launch log ${log.file}${log.missing ? ' (missing)' : ''}`]; for (const r of runs) { lines.push(`run ${r.run} launch ${r.launch.file}:${r.launch.line} sha256 ${r.launch.sha256} ${r.end.length ? `ended ${r.end.at(-1).reason ?? ''}` : 'no end record'}`); if (r.result) lines.push(` result ${r.result.path}${r.result.sha256 ? ` ${r.result.sha256}` : ''}${r.result.ok ? '' : ` FAIL ${r.result.problem}`}`); if (r.snapshot) lines.push(` snapshot ${r.snapshot.path}${r.snapshot.sha256 ? ` ${r.snapshot.sha256}` : ''}${verify ? ` (${r.snapshot.files.filter(f => f.ok).length}/${r.snapshot.files.length} files match)` : ''}`); for (const s of r.stderr ?? []) lines.push(` stderr ${s.path}${s.sha256 ? ` ${s.sha256}` : ''}${s.ok ? '' : ` FAIL ${s.problem}`}`); for (const p of r.problems) lines.push(` problem: ${p}`); } lines.push(verify ? `verified: ${verified ? 'yes' : 'no'}` : 'not verified (pass --verify)'); io.stdout.write(`${lines.join('\n')}\n`); } if (runs.length === 0) throw new CliError(`no launch for thread ${thread.label} in the launch log`, 2); if (verify && !verified) throw new CliError('run evidence failed verification', 1); return report; }