From 172368612c3427d1eafbca5f118a9cfb77a004bf Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Wed, 2 Sep 2026 22:03:46 -0500 Subject: [PATCH 1/2] 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, From b017e66e175c8b7efa2be38fda89978914de72f0 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Wed, 2 Sep 2026 22:06:43 -0500 Subject: [PATCH 2/2] test(capabilities): workspace/tooling selftests + live demo fixture (#21) - mock plumbing cases: workspace path + tools delivered (asserted from run-record stderr), host workspace created, absent fields = empty vars - validation negatives: unknown tool, workspace traversal - tasks/workspace-demo.json: pi uses bash inside the persistent demo workspace; host-visible proof.txt verified live Closes #21 --- scripts/test-task.sh | 32 ++++++++++++++++++++++++++++++++ tasks/workspace-demo.json | 9 +++++++++ 2 files changed, 41 insertions(+) create mode 100644 tasks/workspace-demo.json diff --git a/scripts/test-task.sh b/scripts/test-task.sh index d5506f3d..b4333391 100755 --- a/scripts/test-task.sh +++ b/scripts/test-task.sh @@ -146,6 +146,38 @@ else echo "skip adapter seam cases (docker daemon unavailable)" fi +# ---------- workspace + capabilities (M5): deterministic mock cases ---------- +if docker info >/dev/null 2>&1; then + printf '{"taskVersion":1,"id":"t-ws","prompt":"ignored","workspace":"suitews","capabilities":{"tools":["read","bash"]}}' > "$SANDBOX/ws-task.json" + expect_exit "workspace+tools task runs via mock" 0 -- \ + env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" MOSAIC_MOCK_RESPONSE=MOCKED \ + scripts/run-task.sh run "$SANDBOX/ws-task.json" + WSL="$(ls -dt "$SANDBOX/data/runs"/r-* 2>/dev/null | head -1)" + grep -q '^MOSAIC_WORKSPACE=/var/lib/mosaic/workspaces/suitews$' "$WSL/stderr.txt" 2>/dev/null \ + && grep -q '^MOSAIC_TOOLS=read,bash$' "$WSL/stderr.txt" 2>/dev/null \ + && check "workspace path + tools delivered to adapter" 0 \ + || check "workspace path + tools delivered to adapter" 1 + [ -d "$SANDBOX/data/workspaces/suitews" ] \ + && check "persistent workspace created on host" 0 \ + || check "persistent workspace created on host" 1 + + expect_exit "plain task still runs (no workspace/tools)" 0 -- \ + env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" MOSAIC_MOCK_RESPONSE=MOSAIC_HELLO_OK \ + scripts/run-task.sh run "$SANDBOX/ok.json" + PL="$(ls -dt "$SANDBOX/data/runs"/r-* 2>/dev/null | head -1)" + grep -q '^MOSAIC_WORKSPACE=$' "$PL/stderr.txt" 2>/dev/null \ + && check "workspace var present but empty when absent" 0 || check "workspace var present but empty when absent" 1 + grep -q '^MOSAIC_TOOLS=$' "$PL/stderr.txt" 2>/dev/null \ + && check "tools empty when absent" 0 || check "tools empty when absent" 1 + + printf '{"taskVersion":1,"id":"t-badtool","prompt":"x","capabilities":{"tools":["sudo"]}}' > "$SANDBOX/badtool.json" + expect_exit "unknown tool exits 2" 2 -- $TASK validate "$SANDBOX/badtool.json" + printf '{"taskVersion":1,"id":"t-badws","prompt":"x","workspace":"../escape"}' > "$SANDBOX/badws.json" + expect_exit "workspace traversal exits 2" 2 -- $TASK validate "$SANDBOX/badws.json" +else + echo "skip workspace/capability cases (docker daemon unavailable)" +fi + # ---------- live: real runs (Docker + credentials required) ---------- # On failure, surface the run record + agent stderr BEFORE the sandbox # cleanup destroys them. Never let a wrong-exit mask the real reason. diff --git a/tasks/workspace-demo.json b/tasks/workspace-demo.json new file mode 100644 index 00000000..ed861970 --- /dev/null +++ b/tasks/workspace-demo.json @@ -0,0 +1,9 @@ +{ + "taskVersion": 1, + "id": "t-workspace-demo", + "prompt": "Use the bash tool to create a file named proof.txt in the current directory containing exactly the text: workspace works. Then reply with exactly: WORKSPACE_OK", + "workspace": "demo", + "capabilities": { "tools": ["bash", "read", "write"] }, + "expectExact": "WORKSPACE_OK", + "timeoutSeconds": 180 +}