fix(worker): limit legacy mounts to selected inputs and outputs (#156)

This commit is contained in:
jason.woltje committed 2026-10-29 00:19:26 -05:00
1 parent 09a847edaa
commit ee151685e9
18 files changed
+513 -91

No files matched your search

+10
View File
@@ -5816,3 +5816,13 @@ Before: agents can read the library; supporting-path and push-credential depende
2026-10-26 | Sage | mos1884/#662 scope after330/331landing | ReadQ222/Q261/Q263 and659/660/662; posted bounded human-only factory settings/triage/defaulttwo-roundmandate/trackedno-exemptionflake scope with10acceptance criteria, task662comment1338 exactwholebody/author13GETverified. No implementation/review/gate launched.660c1335notready(review/merge, noVM); gatefacts1329/1334 retained; target integration/fullrunner confirmation follows readiness, no localfallback. Landedproduct/evidence unchanged;657/658backlog.
2026-10-26 | Sage | Mos1909/1915/1917 real postlanding reviews | Boyd/Dewey/Filbert/Gladstone real authored candidate52ba verdictfiles independently byte/hash-verified, allAPPROVE scoped; Dewey656broaderPARTIAL retained. Formal330R1/331R4 requests failedpre-send/no comments or approvals; no borrowed/copied/mintedkeys. LegacyhelperGETname jason.woltje is NOT ownJarvis, ownslotabsent; rows tracking-open/#436, files cited via normalqueue. NewFilbertNEW-330-01 filedtask665 exactreadback, notlandingreopen/gate. Repair646-648/652-656 remainOPEN untilactualcomments;1917 conditionalbacklog routing retained. #662scope1338posted;660PR405REVISE/notready. Product1968inputs unchanged; no source/helper/test/gate edits/runs; peerjournal prefixes preserved.
2026-10-28T22:29:01-05:00 | Vikunja-19 FIX-156 legacy worker mounts BEFORE (late phase receipt) | Isolated worktree clean at exact base09a847edaa25b609bac7728ff83f224708a29399, branch work/vikunja-19-156-q284-worker-mounts. Required queue probe refused in linked worktree and was not bypassed. A2 SHA/whole report, I1 direction and legacy task/interactive/probe callers traced. Focused baseline13/13; TDD RED17 total=13 pass/4 intended failures for absent typed mount builder/per-path task argv. Receipt appended after first source pass rather than backdated; no container/native/live/provider/full-gate/API/PR/commit/push operation.
2026-10-28T22:36:00-05:00 | Vikunja-19 FIX-156 legacy worker mounts AFTER/source-verified | Static Compose root/control mounts removed; typed task/interactive/probe builder emits fixed per-file RO task/mission/user/SOUL/skill/control/fork inputs, only selected workspace/session RW, private mode0600 per-container prompt and no root/run-evidence/sibling/broker path. Unsupported/missing inputs refuse before runtime registration. Exact-base parsed Compose characterization fails with /var/lib/mosaic and /var/lib/mosaic/control present; focused RED13pass/4fail then user-file narrowing RED7pass/2fail; final two-file18/18 pass, syntax/diff checks and direct user+mission prompt mode600 check pass. Author self-review found no remaining Critical/Important source issue; no independent review by explicit no-children scope. No native confinement/container/provider/full gate or generic credential read; no commit/push/PR/API/release.
2026-10-28T22:35:03-05:00 | Correction to immediately preceding FIX-156 AFTER receipt | Its 22:36 timestamp was rounded one minute ahead; actual clock checkpoint was 22:35:03-05:00. Substance and evidence are unchanged.
2026-10-28T22:41:42-05:00 | Vikunja-19 FIX-156 atomic-control continuation BEFORE | Parent intake found the per-file `control/processing.json` bind pins the inode that `state.mjs` later replaces by temp-file rename, allowing a post-mount halt/generation change to escape final entrypoint admission. Exact branch/base remain work/vikunja-19-156-q284-worker-mounts at 09a847edaa25b609bac7728ff83f224708a29399; source-only TDD repair will restore a narrow rename-visible read-only control view without the data-root root, evidence, sibling state, socket, native/container/full-gate/API/credential operations, or integration authority.
2026-10-28T22:44:07-05:00 | Vikunja-19 FIX-156 atomic-control continuation AFTER/correction | Source trace confirmed `state.mjs` publishes `.state-<uuid>` with `renameSync` over `processing.json`; a file bind can retain the old inode. The typed launch now mounts exactly `<dataRoot>/control` at `/var/lib/mosaic/control:ro`, preserving rename-visible final halt/generation admission while leaving the data-root root, run/evidence/workspace/session trees and `bus/broker.sock` absent. Audited source-produced contents are `processing.json`, transient `.state-*`, and `runtimes/`, `stops/`, `run-stops/` runtime/stop metadata with transient `.receipt-*`; no socket producer targets `control/`. This supersedes the 22:36 per-file-control/no-runtime-receipt wording and clarifies the 22:41 "sibling state" phrase: read-only shared runtime/stop metadata is intentional, while sibling data trees remain absent. Focused TDD RED was 10 pass/1 intended fail (missing directory mapping) after real writer halt/clear inode replacements; standalone GREEN 11/11 and final two-file source GREEN 19/19, node checks 4/4, shell syntax and diff check all rc0. Existing native read-only mount negative and container CI were not run and remain required; no Docker/Podman/bwrap/native/live/provider/full gate, credential, commit/push/PR/API/release action.
+7 -4
View File
@@ -28,10 +28,13 @@ RUN chmod 0555 /opt/mosaic/contracts /opt/mosaic/contracts/* \
&& chmod 0555 /opt/mosaic/src /opt/mosaic/src/*.sh \
&& chmod 0555 /opt/mosaic/adapters /opt/mosaic/adapters/*/adapter.sh
# Writable state, workspace, and pi agent directory (auth.json is
# bind-mounted read-only at runtime; nothing is copied into the image).
RUN mkdir -p /var/lib/mosaic /workspace /home/node/.pi/agent \
&& chown -R node:node /var/lib/mosaic /workspace /home/node /opt/app
# Private per-container prompt/output paths, the fixed narrow-mount targets,
# workspace, and pi agent directory (auth.json is bind-mounted read-only at
# runtime; no host data root is mounted).
RUN mkdir -p /var/lib/mosaic/private /var/lib/mosaic/input/user /var/lib/mosaic/input/skills /var/lib/mosaic/control /var/lib/mosaic/session /workspace /home/node/.pi/agent \
&& touch /var/lib/mosaic/input/task.json /var/lib/mosaic/input/mission.json /var/lib/mosaic/input/agent-soul.md /var/lib/mosaic/input/session-fork.jsonl /var/lib/mosaic/control/processing.json \
&& chown -R node:node /var/lib/mosaic /workspace /home/node /opt/app \
&& chmod 0700 /var/lib/mosaic/private /var/lib/mosaic/control
USER node
WORKDIR /workspace
+1 -1
View File
@@ -9,7 +9,7 @@ not implemented (see BRIEF.md, "Explicit exclusions").
One image (`mosaic-poc-agent:0.84.4`, built on `node:24-bookworm-slim`, non-root,
pinned Pi) runs one Pi agent one-shot. Four immutable local contract files are
loaded in fixed order into the generated system prompt
(`/var/lib/mosaic/system-prompt.md`). One real model request is sent
(`/var/lib/mosaic/private/system-prompt.md`, private to that container). One real model request is sent
noninteractively; the response must equal `MOSAIC_HELLO_OK` exactly or the
verification exits nonzero. Authentication is supplied at runtime only
(read-only mounted pi auth file, or a provider API key environment variable).
+14 -4
View File
@@ -170,20 +170,30 @@ Inside the container:
```text
/opt/mosaic/contracts immutable contract files
/var/lib/mosaic generated runtime state (mounted from configured dataRoot)
/workspace agent workspace
/var/lib/mosaic/input selected task/mission/user/role inputs (read-only mounts)
/var/lib/mosaic/control processing and runtime/stop metadata (read-only mount)
/var/lib/mosaic/private generated prompt in this container only
/var/lib/mosaic/session selected session target (read-write when declared)
/workspace selected workspace (read-write when declared)
```
The configured data-root root, run evidence, sibling runs/workspaces/sessions,
control sockets, and broker sockets are not mounted. A task launch exposes
its own task snapshot read-only; mission and fork inputs are read-only; only
the selected workspace and target session are writable. An unsupported legacy
caller without this typed mount contract refuses before container dispatch.
## How it works
1. `scripts/build.sh` builds the release image (`mosaic-poc-agent:<pi>-r<release>`,
tag derived from `RELEASE` + the pinned Pi version) with Docker Compose.
2. On each run, `/opt/mosaic/src/load-contracts.sh` reads the four contract files
in fixed order (CONSTITUTION, STANDARDS, SOUL, USER), joins them with clear
separators, and writes `/var/lib/mosaic/system-prompt.md`.
separators, and writes `/var/lib/mosaic/private/system-prompt.md` in the
disposable container filesystem (never in the host data root).
3. `/opt/mosaic/src/run-agent.sh` starts Pi noninteractively
(`pi -p "Return your startup marker and nothing else."`) with
`--system-prompt "$(cat /var/lib/mosaic/system-prompt.md)"` and all ambient
`--system-prompt /var/lib/mosaic/private/system-prompt.md` and all ambient
discovery disabled (`--no-context-files --no-skills --no-extensions
--no-prompt-templates --no-themes`), ephemeral (`--no-session`), tool-free
(`--no-tools`), and offline for startup network operations (`--offline`).
+3 -2
View File
@@ -21,7 +21,7 @@ system prompt has been generated.
| Variable | Meaning |
|---|---|
| `MOSAIC_SYSTEM_PROMPT_FILE` | Absolute path to the generated system prompt (contracts + optional mission section). Read it; do not modify it. |
| `MOSAIC_SYSTEM_PROMPT_FILE` | Absolute path to the generated per-container system prompt (contracts + optional mission section). Read it; do not modify it. |
| `MOSAIC_REQUEST` | The exact user request text (may contain newlines). |
| `MOSAIC_PROVIDER` | Configured provider name. |
| `MOSAIC_MODEL` | Configured model id. |
@@ -42,7 +42,8 @@ Optional, adapter-specific (documented per adapter):
1. Adapters print ONLY the response on stdout. Status lines go to stderr.
2. Adapters never read configuration files; the resolved settings arrive via environment.
3. Adapters never write outside `/var/lib/mosaic`.
3. Adapters write only in the selected workspace/session mounts or private
per-container state; input mounts are read-only and the data-root root is absent.
4. Adding a worker adapter requires: a new directory, the contract
implementation, and adding the name to the allowlist in
`scripts/mosaic-config.mjs`. A managed-session adapter (below) is selected
+5 -15
View File
@@ -12,22 +12,16 @@ services:
PI_MODEL: ${MOSAIC_MODEL:?MOSAIC_MODEL must be set by scripts/load_config (run via scripts/*.sh)}
# Adapter selection (resolved from config execution.adapter; default pi)
MOSAIC_ADAPTER: ${MOSAIC_ADAPTER:-pi}
# 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:-}
# Per-launch file paths are supplied by the processing-control command
# builder together with their exact narrow mounts. Keeping them out of
# this static service prevents a direct caller from falling back to a
# whole-root view.
# Capabilities (set by the task runner; M5)
MOSAIC_TOOLS: ${MOSAIC_TOOLS:-}
# Persistent named session dir + optional fork source (M6/M11)
MOSAIC_SESSION_DIR: ${MOSAIC_SESSION_DIR:-}
MOSAIC_SESSION_FORK: ${MOSAIC_SESSION_FORK:-}
# Interactive TUI mode + agent identity (M13, set by scripts/agent.sh)
MOSAIC_INTERACTIVE: ${MOSAIC_INTERACTIVE:-}
MOSAIC_AGENT_NAME: ${MOSAIC_AGENT_NAME:-}
MOSAIC_AGENT_ROLE: ${MOSAIC_AGENT_ROLE:-}
MOSAIC_AGENT_SOUL_FILE: ${MOSAIC_AGENT_SOUL_FILE:-}
# Skill dirs explicitly provided to the seat (M17)
MOSAIC_SKILLS: ${MOSAIC_SKILLS:-}
# mock adapter only: verbatim response for deterministic seam tests
MOSAIC_MOCK_RESPONSE: ${MOSAIC_MOCK_RESPONSE:-}
# Documented container auth alternative: provider API key via
@@ -36,10 +30,6 @@ services:
ZAI_API_KEY: ${ZAI_API_KEY:-}
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
volumes:
# Configured runtime state root (from config.json dataRoot).
- ${MOSAIC_DATA_ROOT:?MOSAIC_DATA_ROOT must be set by scripts/load_config (run via scripts/*.sh)}:/var/lib/mosaic
# The whole control directory is protected against unlink and rename-over replacement.
- ${MOSAIC_DATA_ROOT:?MOSAIC_DATA_ROOT must be set by scripts/load_config (run via scripts/*.sh)}/control:/var/lib/mosaic/control:ro
# Runtime credential only: pi auth file mounted READ-ONLY.
# Never copied into the image.
- ${PI_AUTH_FILE:-/home/jwoltje/.pi/agent/auth.json}:/home/node/.pi/agent/auth.json:ro
+3
View File
@@ -1879,3 +1879,6 @@ are never rewritten or removed; corrections are new entries.
2026-10-26 | Sage | mos1884/#662 scope after330/331landing | ReadQ222/Q261/Q263 and659/660/662; posted bounded human-only factory settings/triage/defaulttwo-roundmandate/trackedno-exemptionflake scope with10acceptance criteria, task662comment1338 exactwholebody/author13GETverified. No implementation/review/gate launched.660c1335notready(review/merge, noVM); gatefacts1329/1334 retained; target integration/fullrunner confirmation follows readiness, no localfallback. Landedproduct/evidence unchanged;657/658backlog.
2026-10-26 | Sage | Mos1909/1915/1917 real postlanding reviews | Boyd/Dewey/Filbert/Gladstone real authored candidate52ba verdictfiles independently byte/hash-verified, allAPPROVE scoped; Dewey656broaderPARTIAL retained. Formal330R1/331R4 requests failedpre-send/no comments or approvals; no borrowed/copied/mintedkeys. LegacyhelperGETname jason.woltje is NOT ownJarvis, ownslotabsent; rows tracking-open/#436, files cited via normalqueue. NewFilbertNEW-330-01 filedtask665 exactreadback, notlandingreopen/gate. Repair646-648/652-656 remainOPEN untilactualcomments;1917 conditionalbacklog routing retained. #662scope1338posted;660PR405REVISE/notready. Product1968inputs unchanged; no source/helper/test/gate edits/runs; peerjournal prefixes preserved.
2026-10-28 | Vikunja-19 | FIX-156 isolated legacy worker per-run mounts on work/vikunja-19-156-q284-worker-mounts at exact09a847e | Source/test/docs review-ready; focused18/18 plus prompt/syntax/diff checks green, no native/full gate or commit/push/PR/API; parent owns integration and CI.
2026-10-28 | Vikunja-19 | FIX-156 atomic-control continuation on same uncommitted branch/base | Replaced inode-pinning processing file launch bind with narrow read-only control-directory view; source RED10/11 then GREEN19/19 plus syntax/diff checks, native/container CI still required and parent-owned.
+16
View File
@@ -39,6 +39,17 @@ Task fields: `prompt` (required), `mission` (path), `expectExact`,
`directives[]`, optional governing `capabilities.tools`. Policy: a task may
narrow a mission's tools, never widen; empty intersection = tool-free run.
Legacy worker containers use a typed per-launch mount contract. Task, mission,
user and session-fork inputs are read-only. The host-owned `control/` parent is
read-only so atomic processing-state replacement remains visible; it contains
only processing and runtime/stop metadata, not a socket. Only the selected
workspace and target session are read-write. The host data-root root, run
evidence, sibling run/workspace/session trees and broker/control sockets are
absent, and the generated prompt exists only in the disposable container
filesystem. Missing or unsupported launch inputs refuse before Docker dispatch.
These source properties do not by themselves establish native container
confinement.
## Agent (interactive TUI)
```bash
@@ -49,6 +60,11 @@ Launches an interactive pi TUI inside the container with the four immutable
contracts + optional mission + agent identity as its system prompt,
persistent named session, optional workspace. Exit with `/quit`.
The interactive container uses the same narrow launcher: its selected SOUL,
mission, user context and enabled skills are read-only mounts, while only its
workspace and named session are writable. Role authority is resolved on the
host from the reviewed `roles/*.json`; the data-root root is not mounted.
A seat role (`agent.json` `role`) binds to `roles/<role>.json` (M18): the
contract's tools are a ceiling the seat definition or `--tools` may narrow,
never escalate past. Missing/invalid contract refuses the launch; empty
+12 -5
View File
@@ -30,11 +30,18 @@ Unknown remains unknown and prevents resume. Stop receipts are write-once and
mirrored into reserved bus actions. An interrupted Docker CLI is not evidence
that its container stopped; a container that cannot be inspected stays unknown.
Task containers mount the whole control directory read-only, below the existing
root mount. The image entrypoint uses the shared strict reader and admission
generation. A row-191 upgrade must stop old processing before the first trusted
initialization and align the image, bus and copy in a separately approved window.
Nothing in a code build performs that activation.
Legacy worker containers mount the host-owned `control/` parent read-only so
atomic replacement of `processing.json` remains visible to final admission.
Source-produced contents are `processing.json`, transient `.state-*` files,
and the `runtimes/`, `stops/`, and `run-stops/` metadata trees (including their
transient `.receipt-*` files); no control socket is produced there. The typed
launcher also mounts selected task/mission/user/role inputs read-only and only
the selected workspace/session read-write; it never mounts the data-root root,
run evidence, other run directories or the `bus/` broker socket. The image entrypoint uses
the shared strict reader and admission generation. A row-191 upgrade must stop
old processing before the first trusted initialization and align the image,
bus and copy in a separately approved window. Nothing in a code build performs
that activation.
The Console status and persistent halted banner work read-only. The controls
require the existing authenticated human write path and `--send`; during Q175's
@@ -12,8 +12,16 @@ try {
const args = process.argv.slice(2), tty = args[0] === '--tty';
if (args.length === 1 && args[0] === '--check') process.exit(0);
if (tty) args.shift();
const worker = tty ? {
kind: 'interactive',
workspaceDir: process.env.MOSAIC_WORKSPACE_SOURCE,
sessionDir: process.env.MOSAIC_SESSION_SOURCE,
...(process.env.MOSAIC_MISSION_SOURCE ? { missionFile: process.env.MOSAIC_MISSION_SOURCE } : {}),
...(process.env.MOSAIC_AGENT_SOUL_SOURCE ? { agentSoulFile: process.env.MOSAIC_AGENT_SOUL_SOURCE } : {}),
...(process.env.MOSAIC_SKILL_SOURCES ? { skillDirs: process.env.MOSAIC_SKILL_SOURCES.split(',') } : {}),
} : { kind: 'probe' };
const result = runContainer(system.dataRoot, { kind: 'container', business: 'instance', run: randomUUID() },
{ tty, args, env: process.env, stdio: 'inherit' });
{ tty, args, worker, env: process.env, stdio: 'inherit' });
if (!result.haltGeneration && !result.proc.error && result.proc.status === 0) unregisterRuntime(system.dataRoot, result.managed.id);
if (result.proc.error) throw result.proc.error;
process.exit(result.proc.status ?? 1);
+164 -4
View File
@@ -1,10 +1,166 @@
import { randomUUID } from 'node:crypto';
import { spawnSync, execFile } from 'node:child_process';
import { existsSync, lstatSync, readdirSync, realpathSync } from 'node:fs';
import { basename, isAbsolute, join, relative, resolve } from 'node:path';
import { promisify } from 'node:util';
import { initializeControl, assertProcessing } from './state.mjs';
import { registerRuntime, updateRuntime, runtime, rootTag } from './runtimes.mjs';
const execute = promisify(execFile);
const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
const WORKER_KEYS = new Set([
'kind', 'taskFile', 'missionFile', 'workspaceDir', 'sessionDir',
'sessionForkFile', 'agentSoulFile', 'skillDirs',
]);
const WORKER_ENV = Object.freeze({
MOSAIC_SYSTEM_PROMPT_FILE: '/var/lib/mosaic/private/system-prompt.md',
MOSAIC_TASK_FILE: '',
MOSAIC_USER_DIR: '',
MOSAIC_MISSION_FILE: '',
MOSAIC_AGENT_SOUL_FILE: '',
MOSAIC_WORKSPACE: '',
MOSAIC_SESSION_DIR: '',
MOSAIC_SESSION_FORK: '',
MOSAIC_SKILLS: '',
});
function workerError(message) {
const error = new Error(`worker launch: ${message}`);
error.code = 'processing-worker-launch-invalid';
return error;
}
function components(root, source, what, type) {
if (typeof source !== 'string' || !isAbsolute(source)) throw workerError(`${what} must be an absolute path`);
let stat;
try { stat = lstatSync(source); }
catch { throw workerError(`${what} is missing: ${source}`); }
if (stat.isSymbolicLink() || (type === 'file' ? !stat.isFile() : !stat.isDirectory())) {
throw workerError(`${what} must be a non-symbolic-link ${type}: ${source}`);
}
const canonical = realpathSync(source);
const rel = relative(root, canonical);
if (rel === '' || rel === '..' || rel.startsWith('../') || isAbsolute(rel)) {
throw workerError(`${what} must be below the configured data root: ${source}`);
}
return { source: canonical, parts: rel.split('/'), stat };
}
function exactParts(found, expected, what) {
if (found.parts.length !== expected.length || expected.some((part, i) => part !== null && found.parts[i] !== part)) {
throw workerError(`${what} is outside its allowed data-root path: ${found.source}`);
}
return found;
}
function mount(source, target, readOnly) {
if (source.includes(':')) throw workerError(`mount source contains an unsupported colon: ${source}`);
return Object.freeze({ source, target, readOnly });
}
// Pure legacy-worker launch contract. Every host source occupies a typed slot
// with one fixed container target; arbitrary mounts and the data-root root are
// not accepted. The returned prompt path has no host source and is private to
// this one disposable container filesystem.
export function workerLaunch(dataRoot, input) {
if (!input || typeof input !== 'object' || Array.isArray(input)) throw workerError('worker launch input is required');
for (const key of Object.keys(input)) if (!WORKER_KEYS.has(key)) throw workerError(`unsupported worker launch key: ${key}`);
if (!['task', 'interactive', 'probe'].includes(input.kind)) throw workerError(`unsupported worker launch kind: ${JSON.stringify(input.kind)}`);
const root = realpathSync(resolve(dataRoot));
const environment = { ...WORKER_ENV };
const mounts = [];
const add = (source, target, readOnly = true) => mounts.push(mount(source, target, readOnly));
// Keep the host-owned directory as the read-only view: state publication
// renames a fresh inode over processing.json, which a file bind would pin.
// This package produces only processing.json, transient .state-* files, and
// runtimes/, stops/, run-stops/ metadata here; sockets live outside control/.
const control = exactParts(components(root, join(root, 'control'), 'control directory', 'directory'),
['control'], 'control directory');
add(control.source, '/var/lib/mosaic/control');
const userDir = join(root, 'user');
if (existsSync(userDir)) {
const user = exactParts(components(root, userDir, 'user context directory', 'directory'), ['user'], 'user context directory');
for (const entry of readdirSync(user.source, { withFileTypes: true }).filter(e => e.name.endsWith('.md')).sort((a, b) => a.name.localeCompare(b.name))) {
const file = exactParts(components(root, join(user.source, entry.name), 'user context file', 'file'), ['user', entry.name], 'user context file');
add(file.source, `/var/lib/mosaic/input/user/${entry.name}`);
}
environment.MOSAIC_USER_DIR = '/var/lib/mosaic/input/user';
}
if (input.kind === 'probe') {
for (const key of WORKER_KEYS) if (key !== 'kind' && input[key] !== undefined) throw workerError(`${key} is unsupported for probe launches`);
return Object.freeze({ mounts: Object.freeze(mounts), environment: Object.freeze(environment) });
}
if (input.kind === 'task') {
if (!input.taskFile) throw workerError('taskFile is required for task launches');
if (input.agentSoulFile !== undefined || input.skillDirs !== undefined) throw workerError('role inputs are unsupported for task launches');
const task = exactParts(components(root, input.taskFile, 'taskFile', 'file'), ['runs', null, 'task.json'], 'taskFile');
add(task.source, '/var/lib/mosaic/input/task.json');
environment.MOSAIC_TASK_FILE = '/var/lib/mosaic/input/task.json';
if (input.missionFile) {
const mission = exactParts(components(root, input.missionFile, 'missionFile', 'file'), ['runs', task.parts[1], 'mission.json'], 'missionFile');
add(mission.source, '/var/lib/mosaic/input/mission.json');
environment.MOSAIC_MISSION_FILE = '/var/lib/mosaic/input/mission.json';
}
if (input.workspaceDir) {
const workspace = components(root, input.workspaceDir, 'workspaceDir', 'directory');
const persistent = workspace.parts.length === 2 && workspace.parts[0] === 'workspaces';
const perRun = workspace.parts.length === 3 && workspace.parts[0] === 'runs' && workspace.parts[1] === task.parts[1] && workspace.parts[2] === 'workspace';
if (!persistent && !perRun) throw workerError(`workspaceDir is outside its allowed data-root path: ${workspace.source}`);
add(workspace.source, '/workspace', false);
environment.MOSAIC_WORKSPACE = '/workspace';
}
if (input.sessionDir) {
const session = exactParts(components(root, input.sessionDir, 'sessionDir', 'directory'), ['sessions', null], 'sessionDir');
add(session.source, '/var/lib/mosaic/session', false);
environment.MOSAIC_SESSION_DIR = '/var/lib/mosaic/session';
}
if (input.sessionForkFile) {
if (!input.sessionDir) throw workerError('sessionForkFile requires sessionDir');
const fork = exactParts(components(root, input.sessionForkFile, 'sessionForkFile', 'file'), ['sessions', null, null], 'sessionForkFile');
if (!fork.parts[2].endsWith('.jsonl') || fork.parts[1] === basename(input.sessionDir)) {
throw workerError(`sessionForkFile must be a .jsonl file in a different named session: ${fork.source}`);
}
add(fork.source, '/var/lib/mosaic/input/session-fork.jsonl');
environment.MOSAIC_SESSION_FORK = '/var/lib/mosaic/input/session-fork.jsonl';
}
} else {
if (input.taskFile !== undefined || input.sessionForkFile !== undefined) throw workerError('task inputs are unsupported for interactive launches');
if (!input.workspaceDir) throw workerError('workspaceDir is required for interactive launches');
if (!input.sessionDir) throw workerError('sessionDir is required for interactive launches');
const workspace = exactParts(components(root, input.workspaceDir, 'workspaceDir', 'directory'), ['workspaces', null], 'workspaceDir');
const session = exactParts(components(root, input.sessionDir, 'sessionDir', 'directory'), ['sessions', null], 'sessionDir');
add(workspace.source, '/workspace', false);
add(session.source, '/var/lib/mosaic/session', false);
environment.MOSAIC_WORKSPACE = '/workspace';
environment.MOSAIC_SESSION_DIR = '/var/lib/mosaic/session';
if (input.missionFile) {
const mission = exactParts(components(root, input.missionFile, 'missionFile', 'file'), ['agent-missions', null], 'missionFile');
if (!mission.parts[1].endsWith('.json')) throw workerError(`missionFile must be an agent mission JSON file: ${mission.source}`);
add(mission.source, '/var/lib/mosaic/input/mission.json');
environment.MOSAIC_MISSION_FILE = '/var/lib/mosaic/input/mission.json';
}
if (input.agentSoulFile) {
const soul = exactParts(components(root, input.agentSoulFile, 'agentSoulFile', 'file'), ['agents', null, 'SOUL.md'], 'agentSoulFile');
add(soul.source, '/var/lib/mosaic/input/agent-soul.md');
environment.MOSAIC_AGENT_SOUL_FILE = '/var/lib/mosaic/input/agent-soul.md';
}
const skills = input.skillDirs ?? [];
if (!Array.isArray(skills)) throw workerError('skillDirs must be an array');
const targets = [];
for (const skillDir of skills) {
const skill = exactParts(components(root, skillDir, 'skillDirs entry', 'directory'), ['skills-enabled', null], 'skillDirs entry');
const target = `/var/lib/mosaic/input/skills/${skill.parts[1]}`;
if (targets.includes(target)) throw workerError(`duplicate skill directory: ${skill.source}`);
targets.push(target);
add(skill.source, target);
}
environment.MOSAIC_SKILLS = targets.join(',');
}
return Object.freeze({ mounts: Object.freeze(mounts), environment: Object.freeze(environment) });
}
export function prepareContainer(dataRoot, record) {
initializeControl(dataRoot);
const state = assertProcessing(dataRoot);
@@ -13,16 +169,20 @@ export function prepareContainer(dataRoot, record) {
assertProcessing(dataRoot, state.generation);
return managed;
}
export function containerArgs(record, { tty = false, args = [] } = {}) {
export function containerArgs(record, { tty = false, args = [], worker } = {}) {
if (!worker || !Array.isArray(worker.mounts) || !worker.environment) throw workerError('validated worker launch is required');
const volumes = worker.mounts.flatMap(({ source, target, readOnly }) => ['--volume', `${source}:${target}:${readOnly ? 'ro' : 'rw'}`]);
const environment = Object.entries(worker.environment).flatMap(([key, value]) => ['-e', `${key}=${value}`]);
return ['compose', 'run', '--rm', ...(!tty ? ['-T'] : []), '--name', record.containerName,
'--label', `mosaic.root=${record.root}`, '--label', `mosaic.runtime=${record.id}`,
'-e', `MOSAIC_PROCESSING_GENERATION=${record.generation}`, 'mosaic-agent', ...args];
'-e', `MOSAIC_PROCESSING_GENERATION=${record.generation}`, ...environment, ...volumes, 'mosaic-agent', ...args];
}
export function runContainer(dataRoot, record, { tty = false, args = [], ...options } = {}) {
export function runContainer(dataRoot, record, { tty = false, args = [], worker, ...options } = {}) {
const launch = workerLaunch(dataRoot, worker);
const managed = prepareContainer(dataRoot, record);
assertProcessing(dataRoot, managed.generation);
// Keep this synchronous. The independent broker sweeps the prepublished name while this waits.
const proc = spawnSync('docker', containerArgs(managed, { tty, args }), options);
const proc = spawnSync('docker', containerArgs(managed, { tty, args, worker: launch }), options);
const current = runtime(dataRoot, managed.id);
// An interrupted Docker client is not proof its container exited. Keep that receipt for inspection.
if (!proc.error && proc.status === 0) updateRuntime(dataRoot, managed.id, { phase: 'ended' });
@@ -1,16 +1,150 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { initializeControl, transition } from '../src/state.mjs';
import { assertProcessing, initializeControl, readControl, transition } from '../src/state.mjs';
const api = await import('../src/containers.mjs').catch(e => { if (e.code === 'ERR_MODULE_NOT_FOUND') return {}; throw e; });
const { parse: parseYaml } = createRequire(import.meta.url)('yaml');
function fixture(t) { const root = mkdtempSync(join(tmpdir(), 'processing-container-')); initializeControl(root); t.after(() => rmSync(root, { recursive: true, force: true })); return root; }
const actor = { business: 'demo', human: 'jason', halted: true };
test('static Compose service has no data-root or control-directory mount', () => {
const compose = parseYaml(readFileSync(new URL('../../../compose.yaml', import.meta.url), 'utf8'));
const volumes = compose.services['mosaic-agent'].volumes;
const targets = volumes.map(volume => {
if (typeof volume !== 'string') return volume.target;
const searchFrom = volume.startsWith('${') ? volume.indexOf('}') + 1 : 0;
const sourceEnd = volume.indexOf(':', searchFrom);
return volume.slice(sourceEnd + 1).split(':')[0];
});
assert.deepEqual(targets, ['/home/node/.pi/agent/auth.json']);
});
test('source launch view maps the control directory so actual atomic state replacements stay path-visible', t => {
const root = fixture(t), initial = readControl(root);
const launch = api.workerLaunch(root, { kind: 'probe' });
const control = launch.mounts.find(mount => mount.target === '/var/lib/mosaic/control');
assert.deepEqual(control, {
source: join(root, 'control'), target: '/var/lib/mosaic/control', readOnly: true,
});
const file = join(control.source, 'processing.json');
const initialInode = statSync(file).ino;
const halted = transition(root, { business: 'demo', human: 'jason', halted: true });
assert.notEqual(statSync(file).ino, initialInode, 'the state writer replaces processing.json with a new inode');
const { changed: haltChanged, ...publishedHalt } = halted;
assert.equal(haltChanged, true);
assert.deepEqual(JSON.parse(readFileSync(file, 'utf8')), publishedHalt);
assert.throws(() => assertProcessing(root, initial.generation), error => error.code === 'processing-halted');
const haltedInode = statSync(file).ino;
const cleared = transition(root, { business: 'demo', human: 'jason', halted: false, generation: halted.generation });
assert.notEqual(statSync(file).ino, haltedInode, 'clearing also publishes by atomic replacement');
assert.equal(readControl(root).generation, cleared.generation);
assert.throws(() => assertProcessing(root, initial.generation), error => error.code === 'processing-generation-changed');
});
function workerTree(t) {
const root = fixture(t);
const files = {
task: join(root, 'runs', 'r-own', 'task.json'),
mission: join(root, 'runs', 'r-own', 'mission.json'),
evidence: join(root, 'runs', 'r-own', 'result.json'),
siblingEvidence: join(root, 'runs', 'r-sibling', 'result.json'),
workspace: join(root, 'workspaces', 'own'),
siblingWorkspace: join(root, 'workspaces', 'sibling'),
session: join(root, 'sessions', 'own'),
fork: join(root, 'sessions', 'base', 'turn.jsonl'),
siblingSession: join(root, 'sessions', 'sibling'),
missionInteractive: join(root, 'agent-missions', 'alice.json'),
soul: join(root, 'agents', 'alice', 'SOUL.md'),
skill: join(root, 'skills-enabled', 'review'),
socket: join(root, 'bus', 'broker.sock'),
unrelatedUserFile: join(root, 'user', 'private.bin'),
};
for (const dir of [
join(root, 'runs', 'r-own'), join(root, 'runs', 'r-sibling'), files.workspace,
files.siblingWorkspace, files.session, join(root, 'sessions', 'base'),
files.siblingSession, join(root, 'user'), join(root, 'agent-missions'),
join(root, 'agents', 'alice'), files.skill, join(root, 'bus'),
]) mkdirSync(dir, { recursive: true });
for (const file of [files.task, files.mission, files.evidence, files.siblingEvidence,
files.fork, files.missionInteractive, files.soul, files.socket]) writeFileSync(file, '{}\n');
writeFileSync(join(root, 'user', 'USER.md'), '# User\n');
writeFileSync(files.unrelatedUserFile, 'not prompt context\n');
writeFileSync(join(files.skill, 'SKILL.md'), '# Review\n');
return { root, files };
}
test('task worker launch mounts only selected inputs RO and selected workspace/session RW', t => {
assert.equal(typeof api.workerLaunch, 'function');
const { root, files } = workerTree(t);
const launch = api.workerLaunch(root, {
kind: 'task', taskFile: files.task, missionFile: files.mission,
workspaceDir: files.workspace, sessionDir: files.session, sessionForkFile: files.fork,
});
const byTarget = Object.fromEntries(launch.mounts.map(m => [m.target, m]));
assert.deepEqual(Object.keys(byTarget).sort(), [
'/var/lib/mosaic/control', '/var/lib/mosaic/input/mission.json',
'/var/lib/mosaic/input/session-fork.jsonl', '/var/lib/mosaic/input/task.json',
'/var/lib/mosaic/input/user/USER.md', '/var/lib/mosaic/session', '/workspace',
].sort());
for (const target of ['/var/lib/mosaic/control', '/var/lib/mosaic/input/mission.json',
'/var/lib/mosaic/input/session-fork.jsonl', '/var/lib/mosaic/input/task.json', '/var/lib/mosaic/input/user/USER.md']) {
assert.equal(byTarget[target].readOnly, true, target);
}
assert.equal(byTarget['/workspace'].readOnly, false);
assert.equal(byTarget['/var/lib/mosaic/session'].readOnly, false);
assert.equal(launch.environment.MOSAIC_TASK_FILE, '/var/lib/mosaic/input/task.json');
assert.equal(launch.environment.MOSAIC_MISSION_FILE, '/var/lib/mosaic/input/mission.json');
assert.equal(launch.environment.MOSAIC_WORKSPACE, '/workspace');
assert.equal(launch.environment.MOSAIC_SESSION_DIR, '/var/lib/mosaic/session');
assert.equal(launch.environment.MOSAIC_SESSION_FORK, '/var/lib/mosaic/input/session-fork.jsonl');
assert.equal(launch.environment.MOSAIC_SYSTEM_PROMPT_FILE, '/var/lib/mosaic/private/system-prompt.md');
const sources = launch.mounts.map(m => m.source);
assert.equal(sources.includes(root), false);
for (const hidden of [files.evidence, files.siblingEvidence, files.siblingWorkspace, files.siblingSession, files.socket]) {
assert.equal(sources.includes(hidden), false, hidden);
}
assert.equal(sources.includes(files.unrelatedUserFile), false);
});
test('interactive worker launch keeps role inputs RO and only its workspace/session RW', t => {
assert.equal(typeof api.workerLaunch, 'function');
const { root, files } = workerTree(t);
const launch = api.workerLaunch(root, {
kind: 'interactive', missionFile: files.missionInteractive, agentSoulFile: files.soul,
skillDirs: [files.skill], workspaceDir: files.workspace, sessionDir: files.session,
});
const byTarget = Object.fromEntries(launch.mounts.map(m => [m.target, m]));
assert.equal(byTarget['/var/lib/mosaic/input/mission.json'].readOnly, true);
assert.equal(byTarget['/var/lib/mosaic/input/agent-soul.md'].readOnly, true);
assert.equal(byTarget['/var/lib/mosaic/input/skills/review'].readOnly, true);
assert.equal(byTarget['/var/lib/mosaic/input/user/USER.md'].readOnly, true);
assert.equal(byTarget['/workspace'].readOnly, false);
assert.equal(byTarget['/var/lib/mosaic/session'].readOnly, false);
assert.equal(launch.environment.MOSAIC_AGENT_SOUL_FILE, '/var/lib/mosaic/input/agent-soul.md');
assert.equal(launch.environment.MOSAIC_SKILLS, '/var/lib/mosaic/input/skills/review');
assert.equal(launch.environment.MOSAIC_TASK_FILE, '');
assert.equal(launch.environment.MOSAIC_SESSION_FORK, '');
});
test('unsupported or incomplete worker launch refuses before runtime registration', t => {
assert.equal(typeof api.workerLaunch, 'function');
const { root, files } = workerTree(t);
assert.throws(() => api.workerLaunch(root, { kind: 'task' }), /taskFile/);
assert.throws(() => api.workerLaunch(root, { kind: 'probe', brokerSocket: files.socket }), /unsupported worker launch key/);
assert.throws(() => api.workerLaunch(root, { kind: 'interactive', workspaceDir: files.workspace }), /sessionDir/);
const runtimes = join(root, 'control', 'runtimes');
assert.throws(() => api.runContainer(root, { kind: 'task', business: 'instance', run: 'missing' }, {}), /worker launch/);
assert.equal(existsSync(runtimes) ? readdirSync(runtimes).length : 0, 0);
});
test('container admission publishes its generated name and root/runtime labels before dispatch', t => {
assert.equal(typeof api.prepareContainer, 'function');
const root = fixture(t), r = api.prepareContainer(root, { kind: 'task', business: 'instance', run: 'run-1' });
const args = api.containerArgs(r, { args: ['request'] });
const args = api.containerArgs(r, { args: ['request'], worker: api.workerLaunch(root, { kind: 'probe' }) });
assert.match(r.containerName, /^mosaic-[a-f0-9]{12}-[a-f0-9-]{36}$/);
assert.ok(args.includes(r.containerName)); assert.ok(args.includes(`mosaic.root=${r.root}`));
assert.ok(args.includes(`mosaic.runtime=${r.id}`)); assert.ok(args.includes(`MOSAIC_PROCESSING_GENERATION=${r.generation}`));
@@ -44,6 +44,50 @@ test('halt refuses direct task launch before Docker or a run record; clear allow
assert.equal(record.response, 'normal response'); assert.equal('haltGeneration' in record, false);
});
test('task launch command exposes no data root, sibling evidence, sibling workspace/session or broker socket', t => {
const f = setup(t);
mkdirSync(join(f.f.dataRoot, 'user'), { recursive: true });
writeFileSync(join(f.f.dataRoot, 'user', 'USER.md'), '# User\n');
mkdirSync(join(f.f.dataRoot, 'runs', 'r-sibling'), { recursive: true });
writeFileSync(join(f.f.dataRoot, 'runs', 'r-sibling', 'result.json'), '{}\n');
mkdirSync(join(f.f.dataRoot, 'workspaces', 'sibling'), { recursive: true });
mkdirSync(join(f.f.dataRoot, 'sessions', 'sibling'), { recursive: true });
mkdirSync(join(f.f.dataRoot, 'sessions', 'base'), { recursive: true });
writeFileSync(join(f.f.dataRoot, 'sessions', 'base', 'turn.jsonl'), '{}\n');
mkdirSync(join(f.f.dataRoot, 'bus'), { recursive: true });
writeFileSync(join(f.f.dataRoot, 'bus', 'broker.sock'), 'not reachable\n');
const mission = join(f.root, 'mission.json');
writeFileSync(mission, JSON.stringify({ missionVersion: 1, id: 'm-one', objective: 'bounded', directives: ['stay bounded'] }));
writeFileSync(f.task, JSON.stringify({
taskVersion: 1, id: 'one', prompt: 'request', mission,
workspace: 'selected', session: 'child', sessionForkFrom: 'base',
}));
const result = f.run(['run', f.task]);
assert.equal(result.status, 0, result.stderr);
const args = JSON.parse(readFileSync(join(f.root, 'called'), 'utf8'));
const text = JSON.stringify(args);
assert.doesNotMatch(text, new RegExp(JSON.stringify(f.f.dataRoot + ':/var/lib/mosaic').slice(1, -1)));
for (const hidden of [join(f.f.dataRoot, 'runs', 'r-sibling'), join(f.f.dataRoot, 'workspaces', 'sibling'),
join(f.f.dataRoot, 'sessions', 'sibling'), join(f.f.dataRoot, 'bus', 'broker.sock')]) assert.equal(text.includes(hidden), false, hidden);
const valuesAfter = flag => args.flatMap((v, i) => v === flag ? [args[i + 1]] : []);
const volumes = valuesAfter('--volume');
assert.ok(volumes.includes(`${join(f.f.dataRoot, 'control')}:/var/lib/mosaic/control:ro`));
assert.ok(volumes.some(v => /\/runs\/r-[^/]+\/task\.json:\/var\/lib\/mosaic\/input\/task\.json:ro$/.test(v)));
assert.ok(volumes.some(v => /\/runs\/r-[^/]+\/mission\.json:\/var\/lib\/mosaic\/input\/mission\.json:ro$/.test(v)));
assert.ok(volumes.includes(`${join(f.f.dataRoot, 'workspaces', 'selected')}:/workspace:rw`));
assert.ok(volumes.includes(`${join(f.f.dataRoot, 'sessions', 'child')}:/var/lib/mosaic/session:rw`));
assert.ok(volumes.includes(`${join(f.f.dataRoot, 'sessions', 'base', 'turn.jsonl')}:/var/lib/mosaic/input/session-fork.jsonl:ro`));
const passedEnv = valuesAfter('-e');
for (const value of [
'MOSAIC_TASK_FILE=/var/lib/mosaic/input/task.json',
'MOSAIC_MISSION_FILE=/var/lib/mosaic/input/mission.json',
'MOSAIC_WORKSPACE=/workspace',
'MOSAIC_SESSION_DIR=/var/lib/mosaic/session',
'MOSAIC_SESSION_FORK=/var/lib/mosaic/input/session-fork.jsonl',
'MOSAIC_SYSTEM_PROMPT_FILE=/var/lib/mosaic/private/system-prompt.md',
]) assert.ok(passedEnv.includes(value), value);
});
for (const [script, args] of [['hello.sh', []], ['verify.sh', []], ['agent.sh', ['fixture']]]) {
test(`halt refuses ${script} before Docker, credentials or release alignment`, t => {
const f = setup(t); initializeControl(f.f.dataRoot);
+7 -7
View File
@@ -130,7 +130,7 @@ fs.writeFileSync(process.argv[2], "AGENT_DEF_ROLE=" + (p.role || "") + "\nAGENT_
[ -r "$AGENTS_DIR/$NAME/SOUL.md" ] || { echo "agent: definition dir missing SOUL.md: $AGENTS_DIR/$NAME" >&2; exit 4; }
mkdir -p "$MOSAIC_DEV_DIR/agents/$NAME"
cp "$AGENTS_DIR/$NAME/SOUL.md" "$MOSAIC_DEV_DIR/agents/$NAME/SOUL.md"
export MOSAIC_AGENT_SOUL_FILE="/var/lib/mosaic/agents/$NAME/SOUL.md"
export MOSAIC_AGENT_SOUL_SOURCE="$MOSAIC_DEV_DIR/agents/$NAME/SOUL.md"
# Seat record: written once at instantiation.
SEAT="$MOSAIC_DEV_DIR/agents/$NAME/seat.json"
if [ ! -f "$SEAT" ]; then
@@ -141,7 +141,7 @@ fi
SESSION="${SESSION:-agent-$NAME}"
mkdir -p "$MOSAIC_DEV_DIR/sessions/$SESSION"
export MOSAIC_SESSION_DIR="/var/lib/mosaic/sessions/$SESSION"
export MOSAIC_SESSION_SOURCE="$MOSAIC_DEV_DIR/sessions/$SESSION"
export MOSAIC_AGENT_NAME="$NAME"
[ -n "$ROLE" ] && export MOSAIC_AGENT_ROLE="$ROLE"
export MOSAIC_INTERACTIVE=1
@@ -177,22 +177,22 @@ export MOSAIC_TOOLS="${TOOLS:+$TOOLS}"
SKILLS_LIST="${SKILLS:-$AGENT_DEF_SKILLS}"
if [ -n "$SKILLS_LIST" ]; then
mkdir -p "$MOSAIC_DEV_DIR/skills-enabled"
RESOLVED=""
SOURCES=""
OLDIFS=$IFS; IFS=','
for s in $SKILLS_LIST; do
case "$s" in *[!A-Za-z0-9._-]*|'') echo "agent: invalid skill name: '$s'" >&2; exit 2;; esac
[ -d "$MOSAIC_DEV_DIR/skills-enabled/$s" ] || { echo "agent: skill '$s' is declared but not enabled (scripts/skill.sh activate $s)" >&2; exit 1; }
RESOLVED="${RESOLVED:+$RESOLVED,}/var/lib/mosaic/skills-enabled/$s"
SOURCES="${SOURCES:+$SOURCES,}$MOSAIC_DEV_DIR/skills-enabled/$s"
done
IFS=$OLDIFS
export MOSAIC_SKILLS="$RESOLVED"
export MOSAIC_SKILL_SOURCES="$SOURCES"
fi
if [ -n "$MISSION" ]; then
[ -r "$MISSION" ] || { echo "agent: mission file not readable: $MISSION" >&2; exit 4; }
mkdir -p "$MOSAIC_DEV_DIR/agent-missions"
cp "$MISSION" "$MOSAIC_DEV_DIR/agent-missions/$NAME.json"
export MOSAIC_MISSION_FILE="/var/lib/mosaic/agent-missions/$NAME.json"
export MOSAIC_MISSION_SOURCE="$MOSAIC_DEV_DIR/agent-missions/$NAME.json"
fi
# Workspace (M13): defaults to a persistent per-agent workspace
@@ -201,7 +201,7 @@ fi
[ -n "$WORKSPACE" ] || WORKSPACE="$NAME"
case "$WORKSPACE" in *[!A-Za-z0-9._-]*|'') echo "agent: invalid workspace name" >&2; exit 4;; esac
mkdir -p "$MOSAIC_DEV_DIR/workspaces/$WORKSPACE"
export MOSAIC_WORKSPACE="/var/lib/mosaic/workspaces/$WORKSPACE"
export MOSAIC_WORKSPACE_SOURCE="$MOSAIC_DEV_DIR/workspaces/$WORKSPACE"
echo "agent: launching TUI agent '$NAME' (session: $SESSION, adapter: $MOSAIC_ADAPTER, model: $MOSAIC_MODEL)"
echo "agent: contracts + $([ -n "$MISSION" ] && echo 'mission' || echo 'no mission') loaded; exit the TUI with /quit"
+28 -21
View File
@@ -312,10 +312,13 @@ function runTask(taskFile, options = {}) {
// Immutable input snapshots (evidence of exactly what was executed).
const rawTask = fs.readFileSync(path.resolve(taskFile), "utf8");
writeOnce(path.join(runDir, "task.json"), rawTask);
const taskSnapshotFile = path.join(runDir, "task.json");
writeOnce(taskSnapshotFile, rawTask);
let missionSnapshotFile = null;
if (task.missionSnapshot) {
const missionPath = path.resolve(path.dirname(path.resolve(taskFile)), task.mission);
writeOnce(path.join(runDir, "mission.json"), fs.readFileSync(missionPath, "utf8"));
missionSnapshotFile = path.join(runDir, "mission.json");
writeOnce(missionSnapshotFile, fs.readFileSync(missionPath, "utf8"));
}
const startedAt = new Date();
@@ -334,24 +337,17 @@ function runTask(taskFile, options = {}) {
const release = fs.readFileSync(path.join(PROJECT_ROOT, "RELEASE"), "utf8").trim();
const piVersion = JSON.parse(fs.readFileSync(path.join(PROJECT_ROOT, "package.json"), "utf8")).dependencies["@earendil-works/pi-coding-agent"];
spawnEnv.MOSAIC_IMAGE_TAG = `mosaic-poc-agent:${piVersion}-r${release}`;
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`;
}
// Workspace (M5): create host-side, pass the CONTAINER path.
let workspaceContainerPath = null;
// Workspace (M5): create only the selected host source. The container
// launcher gives it the fixed /workspace target and mounts no parent.
let workspaceDir = null;
if (task.workspace === ":run") {
fs.mkdirSync(path.join(runDir, "workspace"), { recursive: true });
workspaceContainerPath = `/var/lib/mosaic/runs/${runId}/workspace`;
workspaceDir = path.join(runDir, "workspace");
fs.mkdirSync(workspaceDir, { recursive: true });
} else if (task.workspace) {
fs.mkdirSync(path.join(resolved.dataRoot, "workspaces", task.workspace), { recursive: true });
workspaceContainerPath = `/var/lib/mosaic/workspaces/${task.workspace}`;
workspaceDir = path.join(resolved.dataRoot, "workspaces", task.workspace);
fs.mkdirSync(workspaceDir, { recursive: true });
}
if (workspaceContainerPath) spawnEnv.MOSAIC_WORKSPACE = workspaceContainerPath;
// Capability policy (M9): least-privilege intersection. A task may narrow
// a mission's tool grant, never widen it. Empty intersection = tool-free.
@@ -371,14 +367,17 @@ function runTask(taskFile, options = {}) {
if (policyNote) process.stderr.write(`mosaic-task: ${policyNote}\n`);
spawnEnv.MOSAIC_TOOLS = effectiveTools ? effectiveTools.join(",") : "";
// Session (M6): persistent named session dir, passed as container path.
// Session (M6): persistent named session dir, mounted alone at a fixed
// container target by the worker launch contract.
let sessionDir = null;
if (task.session) {
fs.mkdirSync(path.join(resolved.dataRoot, "sessions", task.session), { recursive: true });
spawnEnv.MOSAIC_SESSION_DIR = `/var/lib/mosaic/sessions/${task.session}`;
sessionDir = path.join(resolved.dataRoot, "sessions", task.session);
fs.mkdirSync(sessionDir, { recursive: true });
}
// Session fork (M11): resolve the source session's newest file; pi --fork
// branches it into the target dir without modifying the ancestor.
let sessionForkFile = null;
if (task.sessionForkFrom) {
const sourceDir = path.join(resolved.dataRoot, "sessions", task.sessionForkFrom);
let sources = [];
@@ -390,17 +389,25 @@ function runTask(taskFile, options = {}) {
if (sources.length === 0) {
fail(4, `cannot fork: source session '${task.sessionForkFrom}' has no session files`);
}
const relative = path.relative(resolved.dataRoot, path.join(sourceDir, sources[sources.length - 1]));
sessionForkFile = path.join(sourceDir, sources[sources.length - 1]);
const relative = path.relative(resolved.dataRoot, sessionForkFile);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
fail(4, `source session is outside the configured dataRoot: ${sourceDir}`);
}
spawnEnv.MOSAIC_SESSION_FORK = `/var/lib/mosaic/${relative.split(path.sep).join("/")}`;
}
let launched;
try {
launched = runContainer(resolved.dataRoot, { kind: 'task', business: 'instance', run: runId }, {
args: [task.prompt],
worker: {
kind: 'task',
taskFile: taskSnapshotFile,
...(missionSnapshotFile ? { missionFile: missionSnapshotFile } : {}),
...(workspaceDir ? { workspaceDir } : {}),
...(sessionDir ? { sessionDir } : {}),
...(sessionForkFile ? { sessionForkFile } : {}),
},
cwd: PROJECT_ROOT,
env: spawnEnv,
input: "", // stdin detached: print mode must never wait on a terminal (see issue #5)
+40 -14
View File
@@ -90,6 +90,14 @@ good_mission() {
EOF
}
# Prompt composition is source-tested at an owned output path. Runtime
# launch tests below assert only the private path/mount contract; they never
# treat a host data-root prompt as evidence because no such file should exist.
mkdir -p "$SANDBOX/private-prompt"
PROMPT_FIXTURE="$SANDBOX/private-prompt/system-prompt.md"
env MOSAIC_AGENT_NAME= MOSAIC_AGENT_ROLE= MOSAIC_AGENT_SOUL_FILE= MOSAIC_MISSION_FILE= MOSAIC_USER_DIR= \
src/load-contracts.sh contracts "$PROMPT_FIXTURE" >/dev/null 2>&1
# ---------- fast: schema negatives ----------
good_task "$SANDBOX/ok.json"
expect_exit "valid task validates" 0 -- $TASK validate "$SANDBOX/ok.json"
@@ -194,14 +202,25 @@ EOF
cat > "$SANDBOX/m-seam.json" <<'EOF'
{"missionVersion":1,"id":"m-seam","objective":"Prove the mission injection point.","directives":["Seam directive A.","Seam directive B."]}
EOF
env MOSAIC_AGENT_NAME= MOSAIC_AGENT_ROLE= MOSAIC_AGENT_SOUL_FILE= MOSAIC_USER_DIR= \
MOSAIC_MISSION_FILE="$SANDBOX/m-seam.json" \
src/load-contracts.sh contracts "$PROMPT_FIXTURE" >/dev/null 2>&1
grep -q 'MISSION (runtime)' "$PROMPT_FIXTURE" \
&& grep -q 'Seam directive A.' "$PROMPT_FIXTURE" \
&& check "mission section composed from explicit read-only input" 0 \
|| check "mission section composed from explicit read-only input" 1
printf '{"taskVersion":1,"id":"t-mission-seam","prompt":"ignored by mock","mission":"m-seam.json"}' > "$SANDBOX/seam-task.json"
expect_exit "mission task runs via mock adapter" 0 -- \
env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" MOSAIC_MOCK_RESPONSE=MOCKED \
scripts/run-task.sh run "$SANDBOX/seam-task.json"
grep -q 'MISSION (runtime)' "$SANDBOX/data/system-prompt.md" \
&& grep -q 'Seam directive A.' "$SANDBOX/data/system-prompt.md" \
&& check "mission section injected into generated prompt" 0 \
|| check "mission section injected into generated prompt" 1
MRL="$(ls -dt "$SANDBOX/data/runs"/r-* | head -1)"
grep -q '^MOSAIC_MISSION_FILE=/var/lib/mosaic/input/mission.json$' "$MRL/stderr.txt" \
&& grep -q '^MOSAIC_SYSTEM_PROMPT_FILE=/var/lib/mosaic/private/system-prompt.md$' "$MRL/stderr.txt" \
&& check "mission and private prompt paths delivered to adapter" 0 \
|| check "mission and private prompt paths delivered to adapter" 1
[ ! -e "$SANDBOX/data/system-prompt.md" ] \
&& check "generated prompt is absent from the host data root" 0 \
|| check "generated prompt is absent from the host data root" 1
# retry lineage + relative mission path resolution
expect_exit "retry of mission run succeeds" 0 -- \
@@ -212,9 +231,9 @@ EOF
node -e 'const r=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));process.exit(r.retriedFrom===process.argv[2]?0:1)' \
"$SANDBOX/data/runs/$RT/result.json" "$ORIG" \
&& check "retriedFrom lineage recorded" 0 || check "retriedFrom lineage recorded" 1
grep -q 'MISSION (runtime)' "$SANDBOX/data/system-prompt.md" \
&& check "mission section present after retry (relative path resolved)" 0 \
|| check "mission section present after retry (relative path resolved)" 1
grep -q '^MOSAIC_MISSION_FILE=/var/lib/mosaic/input/mission.json$' "$SANDBOX/data/runs/$RT/stderr.txt" \
&& check "mission input present after retry (relative path resolved)" 0 \
|| check "mission input present after retry (relative path resolved)" 1
expect_exit "retry of missing run exits 4" 4 -- \
env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" node scripts/mosaic-task.mjs retry r-missing
@@ -260,7 +279,7 @@ EOF
FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} seat with enabled skill launches (exit $RC)" >&2
echo "SEATCASE stderr:" >&2; cat "$SANDBOX/seat-stderr.txt" >&2
fi
grep -q '^MOSAIC_SKILLS=/var/lib/mosaic/skills-enabled/ms-tools$' "$SANDBOX/seat-stderr.txt" 2>/dev/null \
grep -q '^MOSAIC_SKILLS=/var/lib/mosaic/input/skills/ms-tools$' "$SANDBOX/seat-stderr.txt" 2>/dev/null \
&& check "skill path delivered to adapter" 0 || check "skill path delivered to adapter" 1
# seat role ceiling (M18): role contracts cap seat capabilities.
@@ -407,13 +426,20 @@ EOF
# live user context (M14): dispatched to every launch without rebuild
mkdir -p "$SANDBOX/data/user"
printf '\nUSER-CANON-MARKER\n' >> "$SANDBOX/data/user/USER.md"
env MOSAIC_AGENT_NAME= MOSAIC_AGENT_ROLE= MOSAIC_AGENT_SOUL_FILE= MOSAIC_MISSION_FILE= \
MOSAIC_USER_DIR="$SANDBOX/data/user" \
src/load-contracts.sh contracts "$PROMPT_FIXTURE" >/dev/null 2>&1
grep -q 'USER CONTEXT: USER.md' "$PROMPT_FIXTURE" \
&& grep -q 'USER-CANON-MARKER' "$PROMPT_FIXTURE" \
&& check "user context composed from explicit read-only input" 0 \
|| check "user context composed from explicit read-only input" 1
expect_exit "task run with user layer present" 0 -- \
env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" MOSAIC_MOCK_RESPONSE=MOSAIC_HELLO_OK \
scripts/run-task.sh run "$SANDBOX/ok.json"
grep -q 'USER CONTEXT: USER.md' "$SANDBOX/data/system-prompt.md" \
&& grep -q 'USER-CANON-MARKER' "$SANDBOX/data/system-prompt.md" \
&& check "user context dispatched into generated prompt" 0 \
|| check "user context dispatched into generated prompt" 1
URL="$(ls -dt "$SANDBOX/data/runs"/r-* | head -1)"
grep -q '^MOSAIC_USER_DIR=/var/lib/mosaic/input/user$' "$URL/stderr.txt" \
&& check "user context input delivered to adapter" 0 \
|| check "user context input delivered to adapter" 1
else
echo "skip adapter seam cases (docker daemon unavailable)"
fi
@@ -425,7 +451,7 @@ if docker info >/dev/null 2>&1; then
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_WORKSPACE=/workspace$' "$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
@@ -567,7 +593,7 @@ else
echo "skip live recall pair: user recall run, recalled user name (docker daemon unavailable)"
fi
# headless run must NOT carry an agent identity section
! grep -q 'AGENT IDENTITY' "$SANDBOX/data/system-prompt.md" 2>/dev/null \
! grep -q 'AGENT IDENTITY' "$PROMPT_FIXTURE" 2>/dev/null \
&& check "no agent identity on headless run" 0 || check "no agent identity on headless run" 1
git -C "$SANDBOX/repo" rev-parse >/dev/null 2>&1 || true
+10 -8
View File
@@ -1,17 +1,19 @@
#!/bin/sh
# Load agent context and write the generated system prompt to
# /var/lib/mosaic/system-prompt.md.
# the caller's private per-container output path.
#
# Layers, in normative order:
# 1. Immutable contracts (image): CONSTITUTION, STANDARDS, SOUL
# 2. Agent identity (when the launcher names the agent)
# 3. Mission (when the task/launcher provides one)
# 4. Live user context (M14): <dataRoot>/user/*.md - user-owned,
# 3. Live user context (M14): selected <dataRoot>/user/*.md files - user-owned,
# dispatched to every launch without rebuilds
# 4. Mission (when the task/launcher provides one)
set -eu
CONTRACT_DIR="${1:-/opt/mosaic/contracts}"
OUT="${2:-/var/lib/mosaic/system-prompt.md}"
OUT="${2:-}"
[ -n "$OUT" ] || { echo "load-contracts: output file is required" >&2; exit 2; }
umask 077
FILES="CONSTITUTION.md STANDARDS.md"
@@ -65,10 +67,10 @@ if [ -n "${MOSAIC_AGENT_NAME:-}" ]; then
printf '\n' >> "$TEMP"
fi
# Live user context (M14): every *.md in /var/lib/mosaic/user (sorted) is
# appended - the user owns this layer and edits it without rebuilds.
USER_DIR="/var/lib/mosaic/user"
if [ -d "$USER_DIR" ]; then
# Live user context (M14): the launcher may mount the selected user input
# directory read-only. No data-root fallback is allowed.
USER_DIR="${MOSAIC_USER_DIR:-}"
if [ -n "$USER_DIR" ] && [ -d "$USER_DIR" ]; then
for f in $(ls "$USER_DIR"/*.md 2>/dev/null | sort); do
printf '===== USER CONTEXT: %s =====\n' "$(basename "$f")" >> "$TEMP"
cat "$f" >> "$TEMP"
+3 -2
View File
@@ -35,8 +35,9 @@ if [ ! -x "$ADAPTER_SCRIPT" ]; then
exit 2
fi
/opt/mosaic/src/load-contracts.sh /opt/mosaic/contracts /var/lib/mosaic/system-prompt.md
: "${MOSAIC_SYSTEM_PROMPT_FILE:?run-agent: MOSAIC_SYSTEM_PROMPT_FILE is required}"
/opt/mosaic/src/load-contracts.sh /opt/mosaic/contracts "$MOSAIC_SYSTEM_PROMPT_FILE"
export MOSAIC_SYSTEM_PROMPT_FILE="/var/lib/mosaic/system-prompt.md"
export MOSAIC_SYSTEM_PROMPT_FILE
exec "$ADAPTER_SCRIPT"