Compare commits

...
2 Commits
18 changed files with 685 additions and 102 deletions

No files matched your search

+14
View File
@@ -5816,3 +5816,17 @@ 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.
2026-10-29T00:29:45-05:00 | Vikunja-19 FIX-156 R3 review repair BEFORE | Independent review report 12437B/SHA256 70daa15fdddfc83654821c99149dc84210a9bd7078614b60ff8e630e4fcb8a77 read in full. Branch is clean at published ee151685e9e15511407904397d73e4d82c37c85b, original base/merge-base 09a847edaa25b609bac7728ff83f224708a29399. F1 source trace confirms only the session-fork file bind is selected from inside a worker-writable directory and remains daemon-resolution TOCTOU; task/mission already use host-owned current-run write-once snapshots. Focused baseline19/19. Controls will require no-follow bounded descriptor copying to same-run evidence, original/foreign source refusal, immutable bytes after original substitution, and probe unsafe-user refusals; no native/container/full gate, install, credential, API, commit/push/amend or review dispatch.
2026-10-29T00:33:07-05:00 | Vikunja-19 FIX-156 R3 review repair AFTER/source-verified | F1 fixed without a new schema: newest session fork is opened O_NOFOLLOW/O_NONBLOCK, fstat regular/current-uid checked, capped at 16777216 bytes before allocation, copied from that descriptor only after stable size/mtime/ctime checks, and published `wx` as current `runs/<id>/session-fork.jsonl`; `workerLaunch` accepts only that exact same-run snapshot and refuses shared-session/foreign-run sources. Real fixture substitution after snapshot cannot change mounted bytes; newest symlink copies/mounts no target; oversize refuses before publication/Docker. F2 documented honest unselected-tree boundary and retained selected managed-business/discord namespace collision; parent follow-up must choose dedicated legacy prefix or explicit collision refusals/migration. F3 probe symlink/nonfile/colon refusals documented and fixture-covered. F4 exact deferred #660 list now includes `scripts/test-darkwing-launch.mjs`; list not run. INFO dispositions: I1 stale comment and unused Docker-client MOSAIC_DATA_ROOT removed with 9pass/1RED then10/10; I2 raw basename comparison eliminated by same-run slot; I3 accepted RO control metadata unchanged; I4 worker-owned private prompt unchanged; I5 gated placeholders unchanged; I6 incomplete predispatch run pattern unchanged; I7 external-path Pi fork remains native #660 evidence; I8 pre-existing Claude adapter env gap unrelated/unchanged. Baseline19/19; principal RED18pass/5intendedfail after one fixture-only setup correction; F1 GREEN23/23; final23/23, four Node checks and diff check rc0. No full package, shell suite, Darkwing compatibility, build, Docker/Podman/bwrap/native/live/provider/network/credential/API/PR/review/commit/amend/push/merge/release action; #660/native/cross-family gates remain.
+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).
+21 -4
View File
@@ -170,20 +170,37 @@ 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, evidence outside the current run, unselected
run/workspace/session trees, control sockets, and broker sockets are not mounted.
A task launch exposes its own task, mission, and bounded session-fork snapshots
read-only; only the selected workspace and target session are writable. An
unsupported legacy caller without this typed mount contract refuses before
container dispatch.
Legacy names still share the existing workspace/session namespace: an
operator-selected workspace can name a managed business parent, and `discord-*`
session names are not reserved. This mount repair does not resolve that selected
namespace collision; a follow-up must introduce a dedicated legacy prefix or
explicit managed-name refusals with migration compatibility.
## 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
+4
View File
@@ -1879,3 +1879,7 @@ 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.
2026-10-29 | Vikunja-19 | FIX-156 R3 repair on published ee151685e without commit/amend | Replaced worker-writable session-fork bind with bounded no-follow current-run write-once snapshot; disposed F2-F4 and I1-I8, RED18/5 then focused23/23, #660/native/review gates remain parent-owned.
+35
View File
@@ -39,6 +39,36 @@ 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 snapshots are read-only. A fork source is opened without
following its final symlink, limited to 16 MiB, checked for owner/type and
concurrent changes, then copied write-once into the current run before launch.
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, evidence outside the current run,
unselected run/workspace/session trees and broker/control sockets are absent,
and the generated prompt exists only in the disposable container filesystem.
Legacy selected names still share namespaces with managed workers: a workspace
name can select a managed business parent and `discord-*` sessions are not
reserved. A dedicated legacy prefix or explicit collision refusal is a separate
migration follow-up. Every launch kind, including `hello.sh` and `verify.sh`
probes, refuses a symlinked `user/`, a symlinked or non-file `user/*.md`, or any
mount source path containing `:`. Missing or unsupported launch inputs also
refuse before Docker dispatch. These source properties do not by themselves
establish native container confinement.
The admitted #660 compatibility gate must run this exact legacy-worker list in
addition to its full repository gate; it was not run by this source repair:
```bash
node --test --test-concurrency=1 packages/processing-control/tests/containers.test.mjs packages/processing-control/tests/coordinator.test.mjs packages/processing-control/tests/effects.test.mjs packages/processing-control/tests/mount.test.mjs packages/processing-control/tests/runtimes.test.mjs packages/processing-control/tests/state.test.mjs packages/processing-control/tests/stop-run.test.mjs packages/processing-control/tests/task.test.mjs
node --test scripts/test-darkwing-launch.mjs
scripts/test-task.sh
scripts/build.sh
```
## Agent (interactive TUI)
```bash
@@ -49,6 +79,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
+17 -5
View File
@@ -30,11 +30,23 @@ 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,
evidence outside the current run, unselected run directories or the `bus/`
broker socket. Session forks are bounded at 16 MiB and copied from a no-follow,
owner/type-checked descriptor into the current run before that snapshot alone is
mounted. Legacy selected workspace/session names can still collide with managed
business parents or `discord-*` sessions; dedicated legacy namespaces or
explicit managed-name refusals require a separate migration. 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);
+162 -4
View File
@@ -1,10 +1,164 @@
import { randomUUID } from 'node:crypto';
import { spawnSync, execFile } from 'node:child_process';
import { existsSync, lstatSync, readdirSync, realpathSync } from 'node:fs';
import { 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'),
['runs', task.parts[1], 'session-fork.jsonl'], 'sessionForkFile');
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 +167,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,184 @@
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, symlinkSync, 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'),
forkSource: join(root, 'sessions', 'base', 'turn.jsonl'),
forkSnapshot: join(root, 'runs', 'r-own', 'session-fork.jsonl'),
foreignForkSnapshot: join(root, 'runs', 'r-sibling', 'session-fork.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.forkSource, files.forkSnapshot, files.foreignForkSnapshot,
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.forkSnapshot,
});
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(byTarget['/var/lib/mosaic/input/session-fork.jsonl'].source, files.forkSnapshot);
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.forkSource, files.foreignForkSnapshot, files.socket]) {
assert.equal(sources.includes(hidden), false, hidden);
}
assert.equal(sources.includes(files.unrelatedUserFile), false);
});
test('task fork slot refuses a shared session file or another run snapshot', t => {
const { root, files } = workerTree(t);
const input = { kind: 'task', taskFile: files.task, sessionDir: files.session };
assert.throws(() => api.workerLaunch(root, { ...input, sessionForkFile: files.forkSource }), /sessionForkFile/);
assert.throws(() => api.workerLaunch(root, { ...input, sessionForkFile: files.foreignForkSnapshot }), /sessionForkFile/);
});
test('probe refuses symlinked or non-file user markdown and colon mount sources', t => {
for (const [name, arrange, expected] of [
['symlinked user directory', root => {
mkdirSync(join(root, 'actual-user')); symlinkSync(join(root, 'actual-user'), join(root, 'user'));
}, /user context directory.*non-symbolic-link/],
['symlinked markdown', root => {
mkdirSync(join(root, 'user')); writeFileSync(join(root, 'context.md'), '# context\n');
symlinkSync(join(root, 'context.md'), join(root, 'user', 'USER.md'));
}, /user context file.*non-symbolic-link/],
['markdown-named directory', root => {
mkdirSync(join(root, 'user', 'USER.md'), { recursive: true });
}, /user context file.*non-symbolic-link/],
['colon source', root => {
mkdirSync(join(root, 'user')); writeFileSync(join(root, 'user', 'USER:.md'), '# context\n');
}, /mount source contains an unsupported colon/],
]) {
const root = fixture(t);
arrange(root);
assert.throws(() => api.workerLaunch(root, { kind: 'probe' }), expected, name);
}
});
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}`));
+100 -3
View File
@@ -1,7 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, readdirSync, symlinkSync, truncateSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { fixture, tmp } from '../../cli/tests/helpers.mjs';
import { initializeControl, transition } from '../src/state.mjs';
@@ -11,8 +11,8 @@ function setup(t, stop = false) {
const source = new URL('../src/state.mjs', import.meta.url).href;
const runtimes = new URL('../src/runtimes.mjs', import.meta.url).href;
writeFileSync(join(bin, 'docker'), `#!${process.execPath}
(async()=>{const args=process.argv.slice(2);require('node:fs').writeFileSync(${JSON.stringify(join(root, 'called'))},JSON.stringify(args));
${stop ? `const {transition}=await import(${JSON.stringify(source)});const {updateRuntime}=await import(${JSON.stringify(runtimes)});const s=transition(process.env.MOSAIC_DATA_ROOT,{business:'acme',human:'jason',halted:true});const tag=args.find(x=>x.startsWith('mosaic.runtime='));if(tag)updateRuntime(process.env.MOSAIC_DATA_ROOT,tag.slice(15),{haltGeneration:s.generation});process.exit(137);` : `console.log('normal response');`}
(async()=>{const args=process.argv.slice(2),fs=require('node:fs');fs.writeFileSync(${JSON.stringify(join(root, 'called'))},JSON.stringify(args));fs.writeFileSync(${JSON.stringify(join(root, 'docker-data-root-env'))},process.env.MOSAIC_DATA_ROOT??'');
${stop ? `const {transition}=await import(${JSON.stringify(source)});const {updateRuntime}=await import(${JSON.stringify(runtimes)});const dataRoot=${JSON.stringify(f.dataRoot)};const s=transition(dataRoot,{business:'acme',human:'jason',halted:true});const tag=args.find(x=>x.startsWith('mosaic.runtime='));if(tag)updateRuntime(dataRoot,tag.slice(15),{haltGeneration:s.generation});process.exit(137);` : `console.log('normal response');`}
})().catch(e=>{console.error(e.message);process.exit(1)});\n`, { mode: 0o755 });
f.env.PATH = bin + ':' + f.env.PATH;
const task = join(root, 'task.json'); writeFileSync(task, JSON.stringify({ taskVersion: 1, id: 'one', prompt: 'request' }));
@@ -44,6 +44,103 @@ 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 });
const forkSource = join(f.f.dataRoot, 'sessions', 'base', 'turn.jsonl');
const forkBytes = Buffer.from('{"type":"message","text":"remember me"}\n');
writeFileSync(forkSource, forkBytes);
const outside = join(f.root, 'outside.txt'); writeFileSync(outside, 'must not replace the fork snapshot\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',
}));
writeFileSync(join(f.root, 'bin', 'docker'), `#!${process.execPath}
const fs=require('node:fs'),args=process.argv.slice(2);
fs.renameSync(${JSON.stringify(forkSource)},${JSON.stringify(forkSource + '.original')});
fs.symlinkSync(${JSON.stringify(outside)},${JSON.stringify(forkSource)});
fs.writeFileSync(${JSON.stringify(join(f.root, 'called'))},JSON.stringify(args));
fs.writeFileSync(${JSON.stringify(join(f.root, 'docker-data-root-env'))},process.env.MOSAIC_DATA_ROOT??'');
console.log('normal response');
`, { mode: 0o755 });
const result = f.run(['run', f.task]);
assert.equal(result.status, 0, result.stderr);
assert.equal(readFileSync(join(f.root, 'docker-data-root-env'), 'utf8'), '');
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, 'sessions', 'base'),
join(f.f.dataRoot, 'bus', 'broker.sock'), outside]) 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`));
const runId = readdirSync(join(f.f.dataRoot, 'runs')).find(name => name !== 'r-sibling');
const forkSnapshot = join(f.f.dataRoot, 'runs', runId, 'session-fork.jsonl');
assert.ok(volumes.includes(`${forkSnapshot}:/var/lib/mosaic/input/session-fork.jsonl:ro`));
assert.deepEqual(readFileSync(forkSnapshot), forkBytes);
assert.equal(lstatSync(forkSource).isSymbolicLink(), true);
assert.equal(readlinkSync(forkSource), outside);
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);
});
test('newest session fork symlink refuses before Docker without copying its target', t => {
const f = setup(t), sourceDir = join(f.f.dataRoot, 'sessions', 'base');
mkdirSync(sourceDir, { recursive: true });
const outside = join(f.root, 'outside.txt'); writeFileSync(outside, 'host target bytes\n');
writeFileSync(join(sourceDir, 'a-older.jsonl'), '{"safe":"older"}\n');
symlinkSync(outside, join(sourceDir, 'z-newest.jsonl'));
writeFileSync(f.task, JSON.stringify({
taskVersion: 1, id: 'one', prompt: 'request', session: 'child', sessionForkFrom: 'base',
}));
const result = f.run(['run', f.task]);
assert.equal(result.status, 4, result.stderr);
assert.match(result.stderr, /session fork source must be a regular, non-symbolic-link file/);
assert.equal(existsSync(join(f.root, 'called')), false);
const [runId] = readdirSync(join(f.f.dataRoot, 'runs'));
assert.deepEqual(readdirSync(join(f.f.dataRoot, 'runs', runId)), ['task.json']);
assert.equal(readFileSync(join(f.f.dataRoot, 'runs', runId, 'task.json'), 'utf8').includes('host target bytes'), false);
});
test('oversized session fork refuses before Docker or snapshot publication', t => {
const f = setup(t), sourceDir = join(f.f.dataRoot, 'sessions', 'base');
mkdirSync(sourceDir, { recursive: true });
const source = join(sourceDir, 'turn.jsonl');
writeFileSync(source, '');
truncateSync(source, 16 * 1024 * 1024 + 1);
writeFileSync(f.task, JSON.stringify({
taskVersion: 1, id: 'one', prompt: 'request', session: 'child', sessionForkFrom: 'base',
}));
const result = f.run(['run', f.task]);
assert.equal(result.status, 4, result.stderr);
assert.match(result.stderr, /session fork source exceeds 16777216 bytes/);
assert.equal(existsSync(join(f.root, 'called')), false);
const [runId] = readdirSync(join(f.f.dataRoot, 'runs'));
assert.equal(existsSync(join(f.f.dataRoot, 'runs', runId, 'session-fork.jsonl')), false);
});
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"
+76 -29
View File
@@ -44,6 +44,7 @@ const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)),
const RUNS_DIRNAME = "runs";
const ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
const DEFAULT_TIMEOUT_SECONDS = 120;
const SESSION_FORK_MAX_BYTES = 16 * 1024 * 1024;
const SUPPORTED_TOOLS = ["read", "write", "edit", "bash", "grep", "find", "ls"]; // pi documented built-ins
function fail(exitCode, message) {
@@ -293,6 +294,38 @@ function writeOnce(file, content) {
}
}
function readSessionFork(file) {
let fd;
try {
fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
const before = fs.fstatSync(fd, { bigint: true });
if (!before.isFile() || before.uid !== BigInt(process.getuid())) {
throw new Error("session fork source must be a regular, non-symbolic-link file owned by this uid");
}
if (before.size > BigInt(SESSION_FORK_MAX_BYTES)) {
throw new Error(`session fork source exceeds ${SESSION_FORK_MAX_BYTES} bytes`);
}
const bytes = Buffer.alloc(Number(before.size) + 1);
let size = 0;
while (size < bytes.length) {
const read = fs.readSync(fd, bytes, size, bytes.length - size, null);
if (read === 0) break;
size += read;
}
const after = fs.fstatSync(fd, { bigint: true });
if (BigInt(size) !== before.size || after.size !== before.size ||
after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs) {
throw new Error("session fork source changed while it was being snapshotted");
}
return bytes.subarray(0, size);
} catch (error) {
if (error.message?.startsWith("session fork source ")) throw error;
throw new Error("session fork source must be a regular, non-symbolic-link file owned by this uid");
} finally {
if (fd !== undefined) fs.closeSync(fd);
}
}
function runTask(taskFile, options = {}) {
const resolved = JSON.parse(
spawnSync(process.execPath, [path.join(PROJECT_ROOT, "scripts", "mosaic-config.mjs"), "validate"], {
@@ -312,17 +345,20 @@ 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();
const stderrFile = path.join(runDir, "stderr.txt");
// Sanctioned mission injection: point the container at the run snapshot's
// CONTAINER path (dataRoot maps to /var/lib/mosaic in the image).
// Container inputs use explicit typed mounts; this environment carries only
// host launcher selection and Compose image identity.
const spawnEnv = { ...process.env };
spawnEnv.MOSAIC_ADAPTER = resolved.execution.adapter;
// Self-sufficient env: direct invocation (e.g. `retry`) skips the shell
@@ -330,28 +366,20 @@ function runTask(taskFile, options = {}) {
// (Names here are the compose interpolation consumers, not PI_*.)
spawnEnv.MOSAIC_PROVIDER = resolved.execution.provider;
spawnEnv.MOSAIC_MODEL = resolved.execution.model;
spawnEnv.MOSAIC_DATA_ROOT = resolved.dataRoot;
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,16 +399,26 @@ 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.
// Session fork (M11): copy the source session's newest file through one
// no-follow descriptor into this host-owned run before any container launch.
// The 16 MiB cap bounds allocation for worker-authored source content.
let sessionForkFile = null;
if (task.sessionForkFrom) {
const sourceDir = path.join(resolved.dataRoot, "sessions", task.sessionForkFrom);
let sourceStat;
try { sourceStat = fs.lstatSync(sourceDir); }
catch { fail(4, `cannot fork: source session dir not found: ${sourceDir}`); }
if (!sourceStat.isDirectory() || sourceStat.isSymbolicLink() || sourceStat.uid !== process.getuid()) {
fail(4, `cannot fork: source session dir must be a non-symbolic-link directory owned by this uid: ${sourceDir}`);
}
let sources = [];
try {
sources = fs.readdirSync(sourceDir).filter((f) => f.endsWith(".jsonl")).sort();
@@ -390,17 +428,26 @@ 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]));
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("/")}`;
const source = path.join(sourceDir, sources[sources.length - 1]);
let bytes;
try { bytes = readSessionFork(source); }
catch (error) { fail(4, `cannot fork: ${error.message}: ${source}`); }
sessionForkFile = path.join(runDir, "session-fork.jsonl");
writeOnce(sessionForkFile, bytes);
}
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"