From 172368612c3427d1eafbca5f118a9cfb77a004bf Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Wed, 2 Sep 2026 22:03:46 -0500 Subject: [PATCH] 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 --- adapters/mock/adapter.sh | 3 +++ adapters/pi/adapter.sh | 14 +++++++++-- compose.yaml | 3 +++ scripts/mosaic-task.mjs | 52 +++++++++++++++++++++++++++++++++++++++- 4 files changed, 69 insertions(+), 3 deletions(-) diff --git a/adapters/mock/adapter.sh b/adapters/mock/adapter.sh index e5fb505a..981a8ddd 100644 --- a/adapters/mock/adapter.sh +++ b/adapters/mock/adapter.sh @@ -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; } 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:-}" diff --git a/adapters/pi/adapter.sh b/adapters/pi/adapter.sh index 3a9bc251..9af212a9 100644 --- a/adapters/pi/adapter.sh +++ b/adapters/pi/adapter.sh @@ -12,11 +12,21 @@ set -eu : "${PI_PROVIDER:?pi adapter: PI_PROVIDER 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): # -p/--print noninteractive: print the response and exit # --system-prompt replace the default prompt with the generated one # --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) exec pi \ --offline \ @@ -26,7 +36,7 @@ exec pi \ --no-prompt-templates \ --no-themes \ --no-context-files \ - --no-tools \ + $TOOLS_FLAG \ --provider "$PI_PROVIDER" \ --model "$PI_MODEL" \ --system-prompt "$(cat "$MOSAIC_SYSTEM_PROMPT_FILE")" \ diff --git a/compose.yaml b/compose.yaml index 027ddca4..292d19cd 100644 --- a/compose.yaml +++ b/compose.yaml @@ -15,6 +15,9 @@ services: # Mission directives injection point (set by the task runner when the # task references a mission; container path of the run snapshot) 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 MOSAIC_MOCK_RESPONSE: ${MOSAIC_MOCK_RESPONSE:-} # Documented container auth alternative: provider API key via diff --git a/scripts/mosaic-task.mjs b/scripts/mosaic-task.mjs index b224cf43..77c4c785 100755 --- a/scripts/mosaic-task.mjs +++ b/scripts/mosaic-task.mjs @@ -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 /workspaces/. + 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,