Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d6dae6fe4 | ||
|
|
ee151685e9 |
No files matched your search
@@ -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
@@ -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,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
@@ -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,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.
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`));
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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