fix(worker): limit legacy mounts to selected inputs and outputs (#156)
This commit is contained in:
1 parent
09a847edaa
commit
ee151685e9
18 files changed
+513
-91
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
Reference in new issue
Block a user