#!/usr/bin/env node /** * Mosaic mission/task layer (M2, host-side only). * * Operations: * validate Strictly validate a task (and its referenced mission); * print the resolved task on success. Writes nothing. * run Execute the task through the config-driven container * path; record an immutable run under /runs/. * list List runs recorded under /runs/. * * Exit codes: * 0 success (run: status "succeeded") * 1 run failed (mismatch, nonzero exit, timeout) — result.json still written * 2 invalid task/mission data * 3 configuration problem * 4 file/environment problem * * M2 scope: mission directives are validated and snapshotted into run * records for provenance; they are NOT yet injected into the runtime * system prompt (capability/policy layer comes later). * * Invariants carried over from the configuration layer: * - Strict schemas; unknown keys rejected; versions pinned at 1. * - Validation never writes. * - Run records are write-once and never rewritten by a later run. * - No secrets in mission/task data or run records. */ import fs from "node:fs"; import path from "node:path"; import process from "node:process"; import { randomBytes } from "node:crypto"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const RUNS_DIRNAME = "runs"; const ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/; const DEFAULT_TIMEOUT_SECONDS = 120; function fail(exitCode, message) { process.stderr.write(`mosaic-task: ${message}\n`); process.exit(exitCode); } function isPlainObject(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } function rejectUnknownKeys(object, allowed, where) { for (const key of Object.keys(object)) { if (!allowed.includes(key)) fail(2, `unsupported ${where} key: "${key}"`); } } function readJsonFile(file, what) { let stat; try { stat = fs.lstatSync(file); } catch { fail(4, `${what} not found: ${file}`); } if (!stat.isFile() || stat.isSymbolicLink()) { fail(4, `${what} must be a regular, non-symbolic-link file: ${file}`); } let document; try { document = JSON.parse(fs.readFileSync(file, "utf8")); } catch (error) { fail(2, `${what} is not valid JSON (${file}): ${error.message}`); } return document; } function validateId(value, what) { if (typeof value !== "string" || !ID_PATTERN.test(value)) { fail(2, `${what} must match ${ID_PATTERN} (got ${JSON.stringify(value)})`); } return value; } function validateMission(document, file) { if (!isPlainObject(document)) fail(2, "mission must be a JSON object"); rejectUnknownKeys(document, ["missionVersion", "id", "objective", "directives"], "mission"); if (document.missionVersion !== 1) { fail(2, `unsupported missionVersion: ${JSON.stringify(document.missionVersion)} (supported: 1)`); } validateId(document.id, "mission id"); if (typeof document.objective !== "string" || document.objective.trim().length === 0 || document.objective.length > 4000) { fail(2, "mission objective must be a non-empty string of at most 4000 characters"); } let directives = []; if (document.directives !== undefined) { if (!Array.isArray(document.directives)) fail(2, "mission directives must be an array of strings"); directives = document.directives.map((d, i) => { if (typeof d !== "string" || d.trim().length === 0 || d.length > 2000 || d.includes("\0")) { fail(2, `mission directive ${i} must be a non-empty string of at most 2000 characters`); } return d; }); } return { missionVersion: document.missionVersion, id: document.id, objective: document.objective, directives }; } function validateTask(document, file) { if (!isPlainObject(document)) fail(2, "task must be a JSON object"); rejectUnknownKeys(document, ["taskVersion", "id", "prompt", "mission", "expectExact", "timeoutSeconds"], "task"); if (document.taskVersion !== 1) { fail(2, `unsupported taskVersion: ${JSON.stringify(document.taskVersion)} (supported: 1)`); } validateId(document.id, "task id"); if (typeof document.prompt !== "string" || document.prompt.length === 0 || document.prompt.length > 20000 || document.prompt.includes("\0")) { fail(2, "task prompt must be a non-empty string of at most 20000 characters"); } let mission = null; let missionId = null; let missionSnapshot = null; if (document.mission !== undefined && document.mission !== null) { if (typeof document.mission !== "string" || document.mission.length === 0) { fail(2, 'task "mission" must be a path string when present'); } const missionPath = path.resolve(path.dirname(path.resolve(file)), document.mission); missionSnapshot = validateMission(readJsonFile(missionPath, "mission file"), missionPath); mission = document.mission; missionId = missionSnapshot.id; } let expectExact = null; if (document.expectExact !== undefined && document.expectExact !== null) { if (typeof document.expectExact !== "string" || document.expectExact.length === 0 || document.expectExact.length > 500 || /[^\P{C}\t\n]/u.test(document.expectExact)) { fail(2, "task expectExact must be a non-empty string of at most 500 characters without control characters (tab/newline allowed)"); } expectExact = document.expectExact; } let timeoutSeconds = DEFAULT_TIMEOUT_SECONDS; if (document.timeoutSeconds !== undefined && document.timeoutSeconds !== null) { if (!Number.isInteger(document.timeoutSeconds) || document.timeoutSeconds < 5 || document.timeoutSeconds > 600) { fail(2, "task timeoutSeconds must be an integer between 5 and 600"); } timeoutSeconds = document.timeoutSeconds; } return { taskVersion: document.taskVersion, id: document.id, prompt: document.prompt, mission, missionId, missionSnapshot, expectExact, timeoutSeconds, }; } function loadConfig() { const proc = spawnSync(process.execPath, [path.join(PROJECT_ROOT, "scripts", "mosaic-config.mjs"), "validate"], { cwd: PROJECT_ROOT, encoding: "utf8", maxBuffer: 1024 * 1024, }); if (proc.status !== 0) { fail(3, `configuration problem (exit ${proc.status}); run scripts/bootstrap.sh or fix config.json`); } return JSON.parse(proc.stdout); } function runsRoot(resolvedConfig) { return path.join(resolvedConfig.dataRoot, RUNS_DIRNAME); } function utcStamp() { return new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z"); } function writeOnce(file, content) { const fd = fs.openSync(file, "wx", 0o644); // exclusive: never overwrite try { fs.writeFileSync(fd, content); } finally { fs.closeSync(fd); } } function runTask(taskFile) { const resolved = JSON.parse( spawnSync(process.execPath, [path.join(PROJECT_ROOT, "scripts", "mosaic-config.mjs"), "validate"], { cwd: PROJECT_ROOT, encoding: "utf8", maxBuffer: 1024 * 1024, }).stdout, ); const task = validateTask(readJsonFile(taskFile, "task file"), path.resolve(taskFile)); const runId = `r-${utcStamp()}-${randomBytes(3).toString("hex")}`; const runDir = path.join(runsRoot(resolved), runId); fs.mkdirSync(runDir, { recursive: true }); // Immutable input snapshots (evidence of exactly what was executed). const rawTask = fs.readFileSync(path.resolve(taskFile), "utf8"); writeOnce(path.join(runDir, "task.json"), rawTask); if (task.missionSnapshot) { const missionPath = path.resolve(path.dirname(path.resolve(taskFile)), task.mission); writeOnce(path.join(runDir, "mission.json"), fs.readFileSync(missionPath, "utf8")); } const startedAt = new Date(); const stderrFile = path.join(runDir, "stderr.txt"); // Sanctioned mission injection: point the container at the run snapshot's // CONTAINER path (dataRoot maps to /var/lib/mosaic in the image). const spawnEnv = { ...process.env }; spawnEnv.MOSAIC_ADAPTER = resolved.execution.adapter; if (task.missionSnapshot) { const relative = path.relative(resolved.dataRoot, runDir); if (relative.startsWith("..") || path.isAbsolute(relative)) { fail(4, `run directory is outside the configured dataRoot: ${runDir}`); } spawnEnv.MOSAIC_MISSION_FILE = `/var/lib/mosaic/${relative.split(path.sep).join("/")}/mission.json`; } const proc = spawnSync( "docker", ["compose", "run", "--rm", "-T", "mosaic-agent", task.prompt], { cwd: PROJECT_ROOT, env: spawnEnv, input: "", // stdin detached: print mode must never wait on a terminal (see issue #5) encoding: "utf8", maxBuffer: 16 * 1024 * 1024, timeout: task.timeoutSeconds * 1000, killSignal: "SIGKILL", }, ); const finishedAt = new Date(); fs.writeFileSync(stderrFile, proc.stderr ?? "", { flag: "wx" }); const response = (proc.stdout ?? "").replace(/^[^\S\n]+/, "").replace(/[^\S\n]+$/, "").trim(); let status = "succeeded"; let reason = null; let expected = task.expectExact; if (proc.error && proc.error.code === "ETIMEDOUT") { status = "failed"; reason = "timeout"; } else if (proc.error) { status = "failed"; reason = `spawn-error: ${proc.error.code ?? proc.error.message}`; } else if (proc.status !== 0) { status = "failed"; reason = "exit-nonzero"; } else if (expected !== null && response !== expected) { status = "failed"; reason = "expect-mismatch"; } const result = { runVersion: 1, runId, taskId: task.id, missionId: task.missionId, status, reason, request: task.prompt, response, expectedExact: expected, exitCode: proc.status, signal: proc.signal ?? null, provider: resolved.execution.provider, model: resolved.execution.model, startedAt: startedAt.toISOString(), finishedAt: finishedAt.toISOString(), durationMs: finishedAt.getTime() - startedAt.getTime(), }; writeOnce(path.join(runDir, "result.json"), `${JSON.stringify(result, null, 2)}\n`); process.stdout.write(`run: ${runId}\nstatus: ${status}${reason ? ` (${reason})` : ""}\nresponse: ${response}\n`); process.exit(status === "succeeded" ? 0 : 1); } function listRuns() { const resolved = loadConfig(); const root = runsRoot(resolved); let entries = []; try { entries = fs.readdirSync(root).filter((name) => name.startsWith("r-")).sort(); } catch { // No runs yet. } for (const runId of entries) { let status = "unknown"; let taskId = "-"; try { const result = JSON.parse(fs.readFileSync(path.join(root, runId, "result.json"), "utf8")); status = result.status; taskId = result.taskId; } catch { // Incomplete run record; report as unknown. } process.stdout.write(`${runId} ${status.padEnd(9)} ${taskId}\n`); } } const operation = process.argv[2]; const target = process.argv[3]; switch (operation) { case "validate": { if (!target) fail(4, "usage: mosaic-task.mjs validate "); const file = path.resolve(target); const task = validateTask(readJsonFile(file, "task file"), file); const { missionSnapshot, ...rest } = task; process.stdout.write(`${JSON.stringify(rest, null, 2)}\n`); process.exit(0); } case "run": if (!target) fail(4, "usage: mosaic-task.mjs run "); runTask(path.resolve(target)); break; case "list": listRuns(); process.exit(0); default: fail(4, `unknown operation: ${JSON.stringify(operation ?? "")} (expected validate | run | list)`); }