feat(capabilities): task workspaces + tools allowlist plumbing (#20)
- task schema: optional workspace (absent | :run ephemeral | named persistent under dataRoot/workspaces) and capabilities.tools (pi documented tool allowlist); strict validation, traversal-proof names - runner: creates host workspace, passes MOSAIC_WORKSPACE (container path) + MOSAIC_TOOLS; result.json records both - pi adapter: cds into workspace; --tools when allowlist present else --no-tools - mock adapter: logs delivered MOSAIC_* vars to stderr as deterministic plumbing evidence (dash prints 'export K=v', so use env not export) Closes #20
This commit is contained in:
+51
-1
@@ -38,6 +38,7 @@ 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;
|
||||
const SUPPORTED_TOOLS = ["read", "write", "edit", "bash", "grep", "find", "ls"]; // pi documented built-ins
|
||||
|
||||
function fail(exitCode, message) {
|
||||
process.stderr.write(`mosaic-task: ${message}\n`);
|
||||
@@ -105,7 +106,7 @@ function validateMission(document, file) {
|
||||
|
||||
function validateTask(document, file) {
|
||||
if (!isPlainObject(document)) fail(2, "task must be a JSON object");
|
||||
rejectUnknownKeys(document, ["taskVersion", "id", "prompt", "mission", "expectExact", "timeoutSeconds"], "task");
|
||||
rejectUnknownKeys(document, ["taskVersion", "id", "prompt", "mission", "expectExact", "timeoutSeconds", "workspace", "capabilities"], "task");
|
||||
if (document.taskVersion !== 1) {
|
||||
fail(2, `unsupported taskVersion: ${JSON.stringify(document.taskVersion)} (supported: 1)`);
|
||||
}
|
||||
@@ -144,6 +145,39 @@ function validateTask(document, file) {
|
||||
timeoutSeconds = document.timeoutSeconds;
|
||||
}
|
||||
|
||||
// Workspace (M5): absent = none; ":run" = ephemeral per-run; otherwise a
|
||||
// persistent named workspace under <dataRoot>/workspaces/<name>.
|
||||
let workspace = null;
|
||||
if (document.workspace !== undefined && document.workspace !== null) {
|
||||
if (typeof document.workspace !== "string" || document.workspace.length === 0) {
|
||||
fail(2, 'task "workspace" must be a non-empty string when present');
|
||||
}
|
||||
if (document.workspace !== ":run") {
|
||||
validateId(document.workspace, "task workspace");
|
||||
}
|
||||
workspace = document.workspace;
|
||||
}
|
||||
|
||||
// Capabilities (M5): optional tools allowlist mapped by adapters to their
|
||||
// native permission flags. Absent = no tools.
|
||||
let tools = null;
|
||||
if (document.capabilities !== undefined && document.capabilities !== null) {
|
||||
if (!isPlainObject(document.capabilities)) fail(2, '"capabilities" must be a JSON object');
|
||||
rejectUnknownKeys(document.capabilities, ["tools"], '"capabilities"');
|
||||
if (!Array.isArray(document.capabilities.tools) || document.capabilities.tools.length === 0) {
|
||||
fail(2, '"capabilities.tools" must be a non-empty array of tool names');
|
||||
}
|
||||
const seen = new Set();
|
||||
for (const tool of document.capabilities.tools) {
|
||||
if (!SUPPORTED_TOOLS.includes(tool)) {
|
||||
fail(2, `unsupported tool: ${JSON.stringify(tool)} (supported: ${SUPPORTED_TOOLS.join(", ")})`);
|
||||
}
|
||||
if (seen.has(tool)) fail(2, `duplicate tool in capabilities.tools: ${tool}`);
|
||||
seen.add(tool);
|
||||
}
|
||||
tools = [...seen];
|
||||
}
|
||||
|
||||
return {
|
||||
taskVersion: document.taskVersion,
|
||||
id: document.id,
|
||||
@@ -153,6 +187,8 @@ function validateTask(document, file) {
|
||||
missionSnapshot,
|
||||
expectExact,
|
||||
timeoutSeconds,
|
||||
workspace,
|
||||
tools,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -223,6 +259,18 @@ function runTask(taskFile) {
|
||||
spawnEnv.MOSAIC_MISSION_FILE = `/var/lib/mosaic/${relative.split(path.sep).join("/")}/mission.json`;
|
||||
}
|
||||
|
||||
// Workspace (M5): create host-side, pass the CONTAINER path.
|
||||
let workspaceContainerPath = null;
|
||||
if (task.workspace === ":run") {
|
||||
fs.mkdirSync(path.join(runDir, "workspace"), { recursive: true });
|
||||
workspaceContainerPath = `/var/lib/mosaic/runs/${runId}/workspace`;
|
||||
} else if (task.workspace) {
|
||||
fs.mkdirSync(path.join(resolved.dataRoot, "workspaces", task.workspace), { recursive: true });
|
||||
workspaceContainerPath = `/var/lib/mosaic/workspaces/${task.workspace}`;
|
||||
}
|
||||
if (workspaceContainerPath) spawnEnv.MOSAIC_WORKSPACE = workspaceContainerPath;
|
||||
spawnEnv.MOSAIC_TOOLS = task.tools ? task.tools.join(",") : "";
|
||||
|
||||
const proc = spawnSync(
|
||||
"docker",
|
||||
["compose", "run", "--rm", "-T", "mosaic-agent", task.prompt],
|
||||
@@ -269,6 +317,8 @@ function runTask(taskFile) {
|
||||
request: task.prompt,
|
||||
response,
|
||||
expectedExact: expected,
|
||||
workspace: task.workspace,
|
||||
tools: task.tools,
|
||||
exitCode: proc.status,
|
||||
signal: proc.signal ?? null,
|
||||
provider: resolved.execution.provider,
|
||||
|
||||
Reference in New Issue
Block a user