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:
2026-09-02 22:03:46 -05:00
parent 1387231e57
commit 172368612c
4 changed files with 69 additions and 3 deletions
+3
View File
@@ -10,4 +10,7 @@ set -eu
[ -r "$MOSAIC_SYSTEM_PROMPT_FILE" ] || { echo "mock adapter: system prompt not readable: $MOSAIC_SYSTEM_PROMPT_FILE" >&2; exit 2; } [ -r "$MOSAIC_SYSTEM_PROMPT_FILE" ] || { echo "mock adapter: system prompt not readable: $MOSAIC_SYSTEM_PROMPT_FILE" >&2; exit 2; }
echo "mock adapter: responding verbatim from MOSAIC_MOCK_RESPONSE" >&2 echo "mock adapter: responding verbatim from MOSAIC_MOCK_RESPONSE" >&2
# Deterministic plumbing evidence: which MOSAIC_* variables did the
# orchestrator actually deliver? (Auth secrets are not MOSAIC_-prefixed.)
(env | grep '^MOSAIC_' | sort) >&2 2>/dev/null || true
printf '%s\n' "${MOSAIC_MOCK_RESPONSE:-}" printf '%s\n' "${MOSAIC_MOCK_RESPONSE:-}"
+12 -2
View File
@@ -12,11 +12,21 @@ set -eu
: "${PI_PROVIDER:?pi adapter: PI_PROVIDER is required}" : "${PI_PROVIDER:?pi adapter: PI_PROVIDER is required}"
: "${PI_MODEL:?pi adapter: PI_MODEL is required}" : "${PI_MODEL:?pi adapter: PI_MODEL is required}"
# Workspace (M5): run inside the provided workspace when present.
if [ -n "${MOSAIC_WORKSPACE:-}" ]; then
mkdir -p "$MOSAIC_WORKSPACE"
cd "$MOSAIC_WORKSPACE"
fi
# Capabilities (M5): explicit allowlist or no tools.
TOOLS_FLAG="--no-tools"
[ -n "${MOSAIC_TOOLS:-}" ] && TOOLS_FLAG="--tools $MOSAIC_TOOLS"
# All flags documented in the pi package README (CLI Reference): # All flags documented in the pi package README (CLI Reference):
# -p/--print noninteractive: print the response and exit # -p/--print noninteractive: print the response and exit
# --system-prompt replace the default prompt with the generated one # --system-prompt replace the default prompt with the generated one
# --no-* no ambient context/skills/extensions/templates/themes # --no-* no ambient context/skills/extensions/templates/themes
# --no-session ephemeral; --no-tools this runtime needs no tools # --no-session ephemeral; TOOLS_FLAG per capabilities
# --offline no startup network operations (update checks/telemetry) # --offline no startup network operations (update checks/telemetry)
exec pi \ exec pi \
--offline \ --offline \
@@ -26,7 +36,7 @@ exec pi \
--no-prompt-templates \ --no-prompt-templates \
--no-themes \ --no-themes \
--no-context-files \ --no-context-files \
--no-tools \ $TOOLS_FLAG \
--provider "$PI_PROVIDER" \ --provider "$PI_PROVIDER" \
--model "$PI_MODEL" \ --model "$PI_MODEL" \
--system-prompt "$(cat "$MOSAIC_SYSTEM_PROMPT_FILE")" \ --system-prompt "$(cat "$MOSAIC_SYSTEM_PROMPT_FILE")" \
+3
View File
@@ -15,6 +15,9 @@ services:
# Mission directives injection point (set by the task runner when the # Mission directives injection point (set by the task runner when the
# task references a mission; container path of the run snapshot) # task references a mission; container path of the run snapshot)
MOSAIC_MISSION_FILE: ${MOSAIC_MISSION_FILE:-} MOSAIC_MISSION_FILE: ${MOSAIC_MISSION_FILE:-}
# Workspace + capabilities (set by the task runner; M5)
MOSAIC_WORKSPACE: ${MOSAIC_WORKSPACE:-}
MOSAIC_TOOLS: ${MOSAIC_TOOLS:-}
# mock adapter only: verbatim response for deterministic seam tests # mock adapter only: verbatim response for deterministic seam tests
MOSAIC_MOCK_RESPONSE: ${MOSAIC_MOCK_RESPONSE:-} MOSAIC_MOCK_RESPONSE: ${MOSAIC_MOCK_RESPONSE:-}
# Documented container auth alternative: provider API key via # Documented container auth alternative: provider API key via
+51 -1
View File
@@ -38,6 +38,7 @@ const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)),
const RUNS_DIRNAME = "runs"; const RUNS_DIRNAME = "runs";
const ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/; const ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
const DEFAULT_TIMEOUT_SECONDS = 120; const DEFAULT_TIMEOUT_SECONDS = 120;
const SUPPORTED_TOOLS = ["read", "write", "edit", "bash", "grep", "find", "ls"]; // pi documented built-ins
function fail(exitCode, message) { function fail(exitCode, message) {
process.stderr.write(`mosaic-task: ${message}\n`); process.stderr.write(`mosaic-task: ${message}\n`);
@@ -105,7 +106,7 @@ function validateMission(document, file) {
function validateTask(document, file) { function validateTask(document, file) {
if (!isPlainObject(document)) fail(2, "task must be a JSON object"); 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) { if (document.taskVersion !== 1) {
fail(2, `unsupported taskVersion: ${JSON.stringify(document.taskVersion)} (supported: 1)`); fail(2, `unsupported taskVersion: ${JSON.stringify(document.taskVersion)} (supported: 1)`);
} }
@@ -144,6 +145,39 @@ function validateTask(document, file) {
timeoutSeconds = document.timeoutSeconds; 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 { return {
taskVersion: document.taskVersion, taskVersion: document.taskVersion,
id: document.id, id: document.id,
@@ -153,6 +187,8 @@ function validateTask(document, file) {
missionSnapshot, missionSnapshot,
expectExact, expectExact,
timeoutSeconds, timeoutSeconds,
workspace,
tools,
}; };
} }
@@ -223,6 +259,18 @@ function runTask(taskFile) {
spawnEnv.MOSAIC_MISSION_FILE = `/var/lib/mosaic/${relative.split(path.sep).join("/")}/mission.json`; 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( const proc = spawnSync(
"docker", "docker",
["compose", "run", "--rm", "-T", "mosaic-agent", task.prompt], ["compose", "run", "--rm", "-T", "mosaic-agent", task.prompt],
@@ -269,6 +317,8 @@ function runTask(taskFile) {
request: task.prompt, request: task.prompt,
response, response,
expectedExact: expected, expectedExact: expected,
workspace: task.workspace,
tools: task.tools,
exitCode: proc.status, exitCode: proc.status,
signal: proc.signal ?? null, signal: proc.signal ?? null,
provider: resolved.execution.provider, provider: resolved.execution.provider,