diff --git a/README.md b/README.md
index 11eccbd..0f6e597 100644
--- a/README.md
+++ b/README.md
@@ -95,6 +95,7 @@ Portability is capability-specific: the compiler reports each feature as **suppo
| Explore complete projects | [Examples](examples/) |
| Add an adapter or contribute | [Contributing](CONTRIBUTING.md) |
| Integrate with deployment tooling | [Target and lifecycle contracts](specs/TARGETS.md) |
+| Plan isolated agent training | [Paideia training handoff](specs/TRAINING.md) |
| Find a detailed contract | [Specification index](specs/INDEX.md) |
Spawnfile is part of [Noopolis](https://github.com/noopolis). [Moltnet](https://moltnet.dev) supplies messaging; [Daimon](https://github.com/noopolis/daimon) runs individual agents; [Simfile](https://simfile.org) builds simulation worlds around organizations. You can use Spawnfile on its own.
diff --git a/package.json b/package.json
index ea2122f..19d59ed 100644
--- a/package.json
+++ b/package.json
@@ -18,7 +18,7 @@
},
"scripts": {
"compile:explicit-test-mcp": "node --experimental-strip-types scripts/compile-explicit-test-mcp.ts",
- "build": "rm -rf dist && tsc --project tsconfig.build.json && chmod +x dist/cli/index.js && node --experimental-strip-types ./src/evidenceExportHelper/copyAssets.ts && node --experimental-strip-types ./src/runtime/copyScaffoldAssets.ts && node --experimental-strip-types ./src/deployment/native/copyArtifacts.ts",
+ "build": "rm -rf dist && tsc --project tsconfig.build.json && chmod +x dist/cli/index.js && node --experimental-strip-types ./src/evidenceExportHelper/copyAssets.ts && node --experimental-strip-types ./src/runtime/copyScaffoldAssets.ts && node --experimental-strip-types ./src/deployment/native/copyArtifacts.ts && node --experimental-strip-types ./src/compiler/training/preparation/copyAssets.ts",
"build:native": "node --experimental-strip-types ./src/deployment/native/build.ts",
"clean": "rm -rf coverage dist",
"coverage": "vitest run --coverage",
@@ -81,5 +81,16 @@
"typescript": "^5.9.3",
"vitest": "^3.2.4"
},
- "packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be"
+ "packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be",
+ "exports": {
+ "./auth": {
+ "types": "./dist/auth/index.d.ts",
+ "import": "./dist/auth/index.js"
+ },
+ "./*": "./*",
+ "./training": {
+ "types": "./dist/compiler/training/index.d.ts",
+ "import": "./dist/compiler/training/index.js"
+ }
+ }
}
diff --git a/runtime-images/training/AGENTS.md b/runtime-images/training/AGENTS.md
new file mode 100644
index 0000000..749d10f
--- /dev/null
+++ b/runtime-images/training/AGENTS.md
@@ -0,0 +1,11 @@
+# Training image
+
+Owns the single-container training dependency recipe. Spawnfile launches and
+stops the outer container; Paideia supervises experiments and native trial child
+processes inside it. No Docker socket or host home is mounted.
+
+Build inputs are explicit package distributions, locked dependency manifests,
+verified native executables and an installed integration entrypoint. Credentials,
+cases and results are runtime mounts, never image contents. Native runtime and
+Python base images must be immutable. Do not download unpinned CLI installers.
+The recipe is opt-in during incubation; no published runtime is implied.
diff --git a/runtime-images/training/CLAUDE.md b/runtime-images/training/CLAUDE.md
new file mode 120000
index 0000000..47dc3e3
--- /dev/null
+++ b/runtime-images/training/CLAUDE.md
@@ -0,0 +1 @@
+AGENTS.md
\ No newline at end of file
diff --git a/runtime-images/training/Dockerfile b/runtime-images/training/Dockerfile
new file mode 100644
index 0000000..741e06a
--- /dev/null
+++ b/runtime-images/training/Dockerfile
@@ -0,0 +1,51 @@
+ARG NATIVE_IMAGE
+ARG PYTHON_IMAGE
+FROM ${PYTHON_IMAGE} AS python
+FROM ${NATIVE_IMAGE} AS training
+
+# Both parents must use the same architecture and Debian release.
+COPY --from=python /usr/local /opt/python
+COPY --from=python /usr/lib/*-linux-gnu/libsqlite3.so.0* /opt/python/lib/
+# The bridge intentionally clears ambient environment variables. Register native
+# libraries in the image so Python extensions also load in that clean process.
+RUN echo /opt/python/lib > /etc/ld.so.conf.d/training-python.conf && ldconfig
+
+WORKDIR /opt/training/paideia
+COPY paideia/package.json paideia/package-lock.json ./
+RUN npm ci --omit=dev --omit=peer --ignore-scripts \
+ && mkdir -p node_modules/@noopolis \
+ && ln -s /opt/spawnfile/runtime-installs/daimon/node_modules/@noopolis/daimon node_modules/@noopolis/daimon
+
+WORKDIR /opt/training/spawnfile
+COPY spawnfile/package.json spawnfile/package-lock.json ./
+RUN npm ci --omit=dev --ignore-scripts
+
+COPY bridge /opt/training/paideia/bridges/dspy
+RUN /opt/python/bin/python3 -m venv /opt/training/paideia/bridges/dspy/.venv \
+ && /opt/training/paideia/bridges/dspy/.venv/bin/pip install --no-cache-dir -r /opt/training/paideia/bridges/dspy/requirements.lock \
+ && /opt/training/paideia/bridges/dspy/.venv/bin/pip install --no-cache-dir setuptools==80.9.0 \
+ && /opt/training/paideia/bridges/dspy/.venv/bin/pip install --no-deps --no-build-isolation -e /opt/training/paideia/bridges/dspy
+
+COPY claude/package.json claude/package-lock.json /opt/training/claude/
+RUN cd /opt/training/claude && npm ci --omit=dev
+COPY grok /opt/training/bin/grok
+
+COPY paideia/dist /opt/training/paideia/dist
+COPY spawnfile/dist /opt/training/spawnfile/dist
+COPY spawnfile/runtimes.yaml spawnfile/moltnet-releases.json /opt/training/spawnfile/
+COPY integration /opt/training/integration
+COPY bootstrap /opt/training/bootstrap
+COPY train /opt/training/bin/train
+RUN chmod 0555 /opt/training/bin/train /opt/training/bin/grok \
+ && chmod -R a+rX /opt/training/bootstrap /opt/training/integration \
+ && ln -s /opt/training/claude/node_modules/.bin/claude /opt/training/bin/claude \
+ && ln -s /opt/training/paideia/dist/src/cli/main.js /opt/training/bin/paideia \
+ && ln -s /opt/training/spawnfile/dist/cli/index.js /opt/training/bin/spawnfile \
+ && mkdir -p /opt/training/integration/node_modules/@noopolis \
+ && ln -s /opt/training/paideia /opt/training/integration/node_modules/@noopolis/paideia \
+ && ln -s /opt/training/spawnfile /opt/training/integration/node_modules/spawnfile \
+ && ln -s /opt/spawnfile/runtime-installs/daimon/node_modules/@noopolis/daimon /opt/training/integration/node_modules/@noopolis/daimon
+ENV PATH=/opt/training/bin:/opt/training/paideia/bridges/dspy/.venv/bin:/opt/spawnfile/runtime-installs/daimon/bin:/usr/local/bin:/usr/bin:/bin
+ENV HOME=/home/training
+WORKDIR /work
+ENTRYPOINT ["/opt/training/bin/train"]
diff --git a/specs/AGENTS.md b/specs/AGENTS.md
index df2162b..c9691c9 100644
--- a/specs/AGENTS.md
+++ b/specs/AGENTS.md
@@ -9,6 +9,8 @@ specs/
├── COMPILER.md # Compiler architecture and internal contracts
├── CONTAINERS.md # Container compilation spec
├── RUNTIMES.md # Runtime registry, version pinning, adapter lifecycle
+├── TRAINING.md # Canonical source handoff and Paideia CLI delegation
+├── TRAINING_CONTAINERS.md # Single-container training launch boundary
├── CAUSAL.md # Shared causal wire and Stele read/verify contract
├── ECOSYSTEM_RUNTIME_BOUNDARIES.md # Cross-project runtime authority and enforcement gates
├── USAGE_ACCOUNTING_DESIGN.md # Daimon turn-usage envelope and Spawnfile aggregation design
diff --git a/specs/INDEX.md b/specs/INDEX.md
index 3254bbc..437ceca 100644
--- a/specs/INDEX.md
+++ b/specs/INDEX.md
@@ -16,6 +16,8 @@ These are the source of truth. Implementation in `src/` must stay aligned with t
| [SURFACES.md](SURFACES.md) | evolving | Communication surfaces — platform messaging, HTTP, webhook, runtime support matrix, and lowering notes |
| [RUNTIMES.md](RUNTIMES.md) | evolving | Runtime registry model — version pinning, status tracking, adapter lifecycle |
| [STATUS.md](STATUS.md) | evolving | Operational status — static and live status, deployment records, Docker targets, runtime probes, and Moltnet metadata-only diagnostics |
+| [TRAINING.md](TRAINING.md) | implemented handoff; native preparation integration required | Canonical agent selection and versioned Paideia delegation, dry-run and source provenance |
+| [TRAINING_CONTAINERS.md](TRAINING_CONTAINERS.md) | single-container launcher; end-to-end validation pending | Whole-experiment image, declared mounts, native auth staging and verified lifecycle |
| [DISTRIBUTION.md](DISTRIBUTION.md) | evolving | Image distribution — self-describing images, sourceless run/status, deployment record v2, publish, registry drift, and the network binding contract |
| [CAUSAL.md](CAUSAL.md) | evolving | Causal event envelope — producer wire rules plus the shared Stele read/verify and reconciliation contract |
| [TARGETS.md](TARGETS.md) | evolving | Project-neutral target-resource public contracts and staged target-adapter boundary |
diff --git a/specs/SPEC.md b/specs/SPEC.md
index 98f5450..e664a10 100644
--- a/specs/SPEC.md
+++ b/specs/SPEC.md
@@ -1749,6 +1749,7 @@ spawnfile model clear-fallbacks [path]
spawnfile validate [path]
spawnfile view [path]
spawnfile compile [path] [--out
]
+spawnfile train [path] [--agent ] --train --test --out [--dry-run]
spawnfile status [path | ] [--out ] [--live] [--deployment ] [--image] [--pull] [--pull-check]
spawnfile up [path | ] [--out ] [--auth-profile ] [--env-file ] [--detach] [--deployment ] [--context ] [--image] [--pull]
spawnfile dev up [path] [--out ] [--auth-profile ] [--env-file ] [--deployment ] [--context ]
@@ -1762,6 +1763,7 @@ spawnfile publish [path] --tag [--out ]
```
See `DISTRIBUTION.md` for `publish`, image-reference `up`/`status`, and the `--image`/`--pull`/`--pull-check` flags.
+See `TRAINING.md` for canonical source handoff, Paideia requirements and delegated training outcomes.
### Exit Codes
@@ -1772,6 +1774,7 @@ All commands share one convention:
- `1` — runtime failure: a compile, build, Docker, or other operation that failed after input validation passed.
Per-command notes below reference this convention rather than restating exit numbers.
+`train` preserves Paideia's completed failed-check exit 1 and cancellation exits 130/143; empty success receipts fail.
#### `spawnfile init`
diff --git a/specs/TRAINING.md b/specs/TRAINING.md
new file mode 100644
index 0000000..9abe17d
--- /dev/null
+++ b/specs/TRAINING.md
@@ -0,0 +1,372 @@
+# Canonical agent training
+
+`spawnfile train` resolves one agent from its full project and delegates to an installed
+Paideia CLI. Spawnfile owns canonical source resolution and native compilation;
+Paideia owns datasets, evaluation, cost planning, optimization and isolated trials.
+
+The [container boundary](TRAINING_CONTAINERS.md) runs the complete experiment
+inside one immutable image. Actual training requires explicit image and mount
+configuration; dry-run remains a host-only estimate.
+
+```sh
+spawnfile train ./Spawnfile --agent agent:writer \
+ --train evals/train.paideia.yaml --test evals/test.paideia.yaml \
+ --editable agents/writer/AGENTS.md --cost-config local-costs.yaml --dry-run
+```
+
+Paideia supplies the cost-config format and training options. Dataset roles are explicit.
+
+`--resume` forwards to Paideia for the same output directory. Paideia validates
+unchanged canonical inputs, the isolated integration's execution identity and
+cumulative budgets before restoring its optimizer and native evidence. Spawnfile
+does not interpret checkpoints, repeat trials or deploy the optimized candidate.
+`--agent` is an exact resolved node ID; omission is allowed only for one-agent projects.
+`--paideia-command` selects an installed executable, default `paideia`; no shell,
+automatic installation, model-provider fallback or production launch is involved.
+
+## Public handoff
+
+The child invocation is `paideia train --spawnfile-context FILE` followed by the
+explicit Paideia options. `FILE` is private evaluator-only JSON, removed after exit.
+Dry-run requires neither `--out` nor an optimizer bridge; actual training requires `--out`.
+The receiver must save any provenance needed later in its own protected experiment.
+The strict `spawnfile.training-context.v1` schema appears below and is generated from
+`src/compiler/training/contract.ts`; it is a wire contract, not an internal import API.
+
+Sources cover the full graph's manifests, resolved documents and skill entry files.
+Every pin has an absolute `sourcePath`, project-relative POSIX `destinationPath`, and
+SHA-256 of the actual file bytes. `destinationPath` preserves source editing locations;
+it is **not** a compiled runtime destination. Source files outside the project root
+are unsupported in v1. Effective documents retain canonical role order and inheritance.
+
+`project.sourceDigest` is SHA-256 of UTF-8 `JSON.stringify` over the `sources` array
+projected to `{destinationPath,sha256}` in that key order, sorted by destinationPath
+using code-point lexical order. All digests use the `sha256:` prefix. Absolute roots
+do not affect this digest. The receiver must revalidate files and mappings before use.
+
+Resources disclose declaration digests and pins, not mounted or verified archives.
+The resource definition digest uses the compiler's recursively key-sorted JSON.
+`pin` is the declared bundle SHA or Git `ref`; branch/tag-only Git and volumes use null.
+This receipt is not a complete packaged-resource closure. No environment values,
+transport configuration, resource URLs or credentials are serialized.
+
+`agent.engine` is an explicit runtime engine option or null. Model identity/auth method
+use canonical model resolution; absent native model defaults remain null. Runtime-added
+instructions, tool schemas, skills loaded at runtime, and native model defaults must be
+established from actual compilation/runtime receipts, never invented from this context.
+
+## Execution and outcomes
+
+Dry-run resolves local sources and lets Paideia validate datasets and estimate costs.
+It does not compile, use Docker/auth, call models, or start an optimizer. Its final JSON
+receipt must have `schema: paideia.training-cost-plan.v1` and `modelCallsMade: 0`.
+Unimplemented native preparation is reported as unsupported, not ready to execute.
+
+Actual execution requires a supported preparation integration. Each candidate must
+reach native files through Spawnfile compilation; no generic Pi fallback, second agent
+declaration or replacement flattened prompt is authorized by this entrypoint. Packaging
+exclusion, state isolation and single-agent preparation are not supplied by this handoff.
+
+Repeated options: `--editable`, `--resource`, `--judge`, `--judge-citation-repairs`, `--validation-group`. Other
+forwarded options: `--train`, `--test`, `--optimizer-model`, `--bridge-command`, `--out`,
+`--max-trials`, `--max-proposals`, `--seed`, `--timeout-ms`, `--view`, `--cost-config`.
+The canonical runtime/model/instruction selection cannot be replaced by generic CLI flags.
+
+`--judge-citation-repairs NAME=0|1` is forwarded literally to Paideia. The receiver
+requires a matching named judge, unique selections and an exact `0` or `1` before
+starting models. Default `0` preserves one judge call per check; `1` reserves one
+additional bounded citation repair. It never retries valid quality failures,
+authentication/quota failures or malformed JSON. Dry-run records the route policy
+and reserves both judge attempts without adding subject trials or optimizer proposals.
+
+Exit 0 requires the mode's final receipt. Completed actual runs also require
+`status: completed` and a nonempty `index` path; exit 1 preserves completed failed checks.
+Receiver error exits are propagated; empty success is a runtime failure. A supervisor
+retains the owned POSIX process-group identity after the native child exits. Cancellation
+forwards SIGTERM and escalates after one second; completion also removes group stragglers.
+The parent verifies group/output quiescence before returning 0 or cancellation 130/143;
+unknown cleanup is a runtime error. No signal uses an identity after its supervisor is reaped.
+This delegation requires POSIX process groups; escaped processes and remote effects are not observed.
+The child deadline is Paideia's declared deadline plus five seconds for cleanup.
+
+## JSON Schema
+
+
+```json
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "version": {
+ "type": "string",
+ "const": "spawnfile.training-context.v1"
+ },
+ "producer": {
+ "type": "object",
+ "properties": {
+ "package": {
+ "type": "string",
+ "const": "spawnfile"
+ },
+ "version": {
+ "type": "string",
+ "minLength": 1
+ }
+ },
+ "required": ["package", "version"],
+ "additionalProperties": false
+ },
+ "project": {
+ "type": "object",
+ "properties": {
+ "root": {
+ "type": "string",
+ "minLength": 1,
+ "pattern": "^(?:\\/|[A-Za-z]:[\\\\/])"
+ },
+ "manifest": {
+ "type": "string",
+ "minLength": 1,
+ "pattern": "^(?:\\/|[A-Za-z]:[\\\\/])"
+ },
+ "sourceDigest": {
+ "type": "string",
+ "pattern": "^sha256:[a-f0-9]{64}$"
+ }
+ },
+ "required": ["root", "manifest", "sourceDigest"],
+ "additionalProperties": false
+ },
+ "agent": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "minLength": 1
+ },
+ "name": {
+ "type": "string",
+ "minLength": 1
+ },
+ "source": {
+ "type": "string",
+ "minLength": 1,
+ "pattern": "^(?:\\/|[A-Za-z]:[\\\\/])"
+ },
+ "runtime": {
+ "type": "string",
+ "minLength": 1
+ },
+ "engine": {
+ "anyOf": [
+ {
+ "type": "string",
+ "minLength": 1
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "model": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "type": "string",
+ "minLength": 1
+ },
+ "name": {
+ "type": "string",
+ "minLength": 1
+ },
+ "authMethod": {
+ "type": "string",
+ "minLength": 1
+ }
+ },
+ "required": ["provider", "name", "authMethod"],
+ "additionalProperties": false
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ "required": ["id", "name", "source", "runtime", "engine", "model"],
+ "additionalProperties": false
+ },
+ "sources": {
+ "minItems": 1,
+ "maxItems": 10000,
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "sourcePath": {
+ "type": "string",
+ "minLength": 1,
+ "pattern": "^(?:\\/|[A-Za-z]:[\\\\/])"
+ },
+ "destinationPath": {
+ "type": "string",
+ "minLength": 1,
+ "pattern": "^(?!\\/)(?!.*(?:^|\\/)\\.\\.(?:\\/|$))[^\\\\]+$"
+ },
+ "sha256": {
+ "type": "string",
+ "pattern": "^sha256:[a-f0-9]{64}$"
+ }
+ },
+ "required": ["sourcePath", "destinationPath", "sha256"],
+ "additionalProperties": false
+ }
+ },
+ "documents": {
+ "maxItems": 128,
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "sourcePath": {
+ "type": "string",
+ "minLength": 1,
+ "pattern": "^(?:\\/|[A-Za-z]:[\\\\/])"
+ },
+ "destinationPath": {
+ "type": "string",
+ "minLength": 1,
+ "pattern": "^(?!\\/)(?!.*(?:^|\\/)\\.\\.(?:\\/|$))[^\\\\]+$"
+ },
+ "sha256": {
+ "type": "string",
+ "pattern": "^sha256:[a-f0-9]{64}$"
+ },
+ "role": {
+ "type": "string",
+ "minLength": 1
+ }
+ },
+ "required": ["sourcePath", "destinationPath", "sha256", "role"],
+ "additionalProperties": false
+ }
+ },
+ "skills": {
+ "maxItems": 1000,
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "sourcePath": {
+ "type": "string",
+ "minLength": 1,
+ "pattern": "^(?:\\/|[A-Za-z]:[\\\\/])"
+ },
+ "destinationPath": {
+ "type": "string",
+ "minLength": 1,
+ "pattern": "^(?!\\/)(?!.*(?:^|\\/)\\.\\.(?:\\/|$))[^\\\\]+$"
+ },
+ "sha256": {
+ "type": "string",
+ "pattern": "^sha256:[a-f0-9]{64}$"
+ },
+ "name": {
+ "type": "string",
+ "minLength": 1
+ },
+ "ref": {
+ "type": "string",
+ "minLength": 1
+ },
+ "requiresMcp": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "minLength": 1
+ }
+ }
+ },
+ "required": ["sourcePath", "destinationPath", "sha256", "name", "ref", "requiresMcp"],
+ "additionalProperties": false
+ }
+ },
+ "resources": {
+ "maxItems": 1000,
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "minLength": 1
+ },
+ "kind": {
+ "type": "string",
+ "enum": [
+ "bundle",
+ "git",
+ "volume"
+ ]
+ },
+ "mount": {
+ "type": "string",
+ "minLength": 1
+ },
+ "mode": {
+ "type": "string",
+ "enum": [
+ "mutable",
+ "readonly"
+ ]
+ },
+ "sharing": {
+ "type": "string",
+ "enum": [
+ "per_agent",
+ "team"
+ ]
+ },
+ "definitionDigest": {
+ "type": "string",
+ "pattern": "^sha256:[a-f0-9]{64}$"
+ },
+ "pin": {
+ "anyOf": [
+ {
+ "type": "string",
+ "minLength": 1
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ "required": ["id", "kind", "mount", "mode", "sharing", "definitionDigest", "pin"],
+ "additionalProperties": false
+ }
+ },
+ "requirements": {
+ "type": "object",
+ "properties": {
+ "nativeCompilation": {
+ "type": "boolean",
+ "const": true
+ },
+ "isolatedPreparation": {
+ "type": "boolean",
+ "const": true
+ }
+ },
+ "required": ["nativeCompilation", "isolatedPreparation"],
+ "additionalProperties": false
+ }
+ },
+ "required": ["version", "producer", "project", "agent", "sources", "documents", "skills", "resources", "requirements"],
+ "additionalProperties": false
+}
+```
+
diff --git a/specs/TRAINING_CONTAINERS.md b/specs/TRAINING_CONTAINERS.md
new file mode 100644
index 0000000..9338270
--- /dev/null
+++ b/specs/TRAINING_CONTAINERS.md
@@ -0,0 +1,102 @@
+# Training in one container
+
+`spawnfile train` runs the complete model-bearing experiment in one immutable
+Docker image: Paideia, DSPy, the integration, Daimon and inference CLIs. The host
+validates declarations, mounts explicit inputs, forwards output and supervises
+container termination. No Docker socket enters the container. Dry-run remains a
+host-only estimate and does not use Docker or model authentication.
+
+## Launch contract
+
+Actual training requires both `--training-image` (a digest reference or immutable
+image ID already installed locally) and `--training-config` (JSON below).
+`--paideia-command` applies only to dry-run; actual execution always starts the
+image-owned `/opt/training/bin/train`. No shell or executable from the host is
+mounted or selected. The image must contain all dependencies and its integration.
+
+```json
+{
+ "version": "spawnfile.training-container.v1",
+ "dockerContext": "desktop-linux",
+ "inputs": [
+ { "source": "/absolute/project", "destination": "/run/training/inputs/project" },
+ { "source": "/absolute/evaluation", "destination": "/run/training/inputs/integration" }
+ ],
+ "output": { "source": "/absolute/generated/run", "destination": "/run/training/output" },
+ "auth": [
+ { "source": "/absolute/credential-leaf", "provider": "codex" }
+ ]
+}
+```
+
+All sources must already exist at exactly canonical paths (no `..`, redundant
+separators or symlink aliases). Host input roots must not overlap. Inputs are read-only and
+cannot overlap the writable output root. Auth declarations accept only regular
+leaf files, mounted read-only at `/run/paideia-auth/`; no whole CLI home
+or configuration directory is accepted. This checks declared paths, not arbitrary
+secret contents inside user-selected input bytes. Input roots must contain only
+the experiment's intended source and evidence.
+
+The selected Docker context must resolve to a local Unix socket. Remote daemon
+bind staging is unsupported. The launcher resolves the pinned image before
+creating a uniquely labelled container, then uses the immutable image ID.
+
+The image entrypoint receives:
+
+```text
+/opt/training/bin/train train --spawnfile-context /run/paideia/context.json ...
+```
+
+Canonical context source paths and CLI dataset/resource/output paths are mapped
+through the declared bindings. YAML-relative data paths continue to resolve in
+that mapped dataset tree. Absolute paths embedded inside integration settings or
+YAML must already use container paths; the launcher does not rewrite arbitrary
+file contents. Bridge executables must already exist under `/opt/training`.
+Runtime `HOME=/home/training`, `/tmp` and `/work` are fresh writable tmpfs mounts;
+the image root is read-only. Launch uses the non-root host uid/gid, drops all
+capabilities and keeps no-new-privileges. The existing Codex native namespace
+compatibility options disable the outer seccomp/AppArmor profiles; native sandbox
+preflight must still verify the agent boundary before cognition. Image startup
+owns its runtime configuration.
+
+`--view ` accepts one explicit port from 1 to 65535 and publishes it only
+on host `127.0.0.1` at that same port. Port zero is unsupported in container mode.
+The trusted image integration binds its Paideia viewer to the container interface;
+public URLs still use host loopback. Removing the owned container removes that
+port mapping. Persisted events also remain available for later local replay.
+
+## Native subscription bootstrap
+
+The public `spawnfile/auth` module exports `stageTrainingAuth({home,provider,source?})`.
+It copies opaque bytes from `/run/paideia-auth/` by default into an
+existing canonical runtime home. An explicit provisioned source leaf is accepted.
+It creates only the fixed private directory and native auth leaf, never overwrites
+an existing or refreshed credential, and returns a non-secret versioned receipt.
+
+| Provider | Destination relative to runtime home |
+| --- | --- |
+| codex | `.daimon-inbound/codex-auth` |
+| grok | `.grok/auth.json` |
+| claude | `.claude/.credentials.json` |
+
+The image startup can stage Grok/Claude into its clean shared home. A native trial
+preparation callback stages Codex into that trial's fresh home before Daimon
+starts. Credentials remain writable only inside the runtime home; renewed state
+is not silently copied back to the host bootstrap file.
+
+## Completion and cancellation
+
+The host forwards container stdout/stderr and requires the final Paideia training
+receipt, a matching stopped container exit status, and a real completion artifact
+within the declared output root. A successful `docker create` or client exit alone
+never means the experiment completed.
+
+Cancellation and timeout stop the Docker client and force-remove only a container
+whose exact ID, unique ownership label, name and image match. Absence is checked
+through a successful Docker listing. Unknown cleanup is an error and preserves
+its private mounted context for diagnosis; it is never reported as quiescent.
+
+This is a single-container boundary. Native Daimon sandbox policy still controls
+individual agent access inside it. The image build, usable private integration,
+authenticated model run and live terminal receipt require end-to-end verification
+before calling this deployment ready.
diff --git a/src/auth/AGENTS.md b/src/auth/AGENTS.md
index de826dd..15c2046 100644
--- a/src/auth/AGENTS.md
+++ b/src/auth/AGENTS.md
@@ -7,6 +7,7 @@ This folder owns Spawnfile-managed auth profiles and auth import flows.
```text
src/auth/
├── index.ts # Barrel exports
+├── trainingAuth.ts # Public opaque training ingress staging to fixed native leaves
├── types.ts # Auth profile types
├── paths.ts # Spawnfile auth home and profile path helpers
├── profileStore.ts # Read/write auth profiles and imported auth material
diff --git a/src/auth/index.ts b/src/auth/index.ts
index 646efa8..826a4e9 100644
--- a/src/auth/index.ts
+++ b/src/auth/index.ts
@@ -10,3 +10,5 @@ export * from "./credentialProvisioningRequest.js";
export * from "./credentialWorldBindings.js";
export * from "./targetSecretSourceLifecycle.js";
export * from "./targetSecretSourceResolver.js";
+export { stageTrainingAuth } from "./trainingAuth.js";
+export type { TrainingAuthStageOptions, TrainingAuthProvider } from "./trainingAuth.js";
diff --git a/src/auth/trainingAuth.failure.test.ts b/src/auth/trainingAuth.failure.test.ts
new file mode 100644
index 0000000..4bcd4be
--- /dev/null
+++ b/src/auth/trainingAuth.failure.test.ts
@@ -0,0 +1,38 @@
+import { mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from "node:fs/promises";
+import path from "node:path";
+import os from "node:os";
+import { afterEach, expect, it, vi } from "vitest";
+const control = vi.hoisted(()=>({ failWrite:false, mutateSource:false, growSource:false }));
+vi.mock("node:fs/promises",async(importOriginal)=>{
+ const native=await importOriginal();
+ return {...native,realpath:async(...args:Parameters)=>{
+ if(String(args[0]).startsWith("/run/paideia-auth/")) throw Error("fixed ingress intercepted for test: "+args[0]);
+ return native.realpath(...args);
+ },open:async(...args:Parameters)=>{
+ const file=await native.open(...args);
+ if(String(args[0]).includes(".stage-") && control.failWrite) file.writeFile=async()=>{throw Error("injected disk full");};
+ if(!String(args[0]).includes(".stage-") && control.growSource) {
+ const original=file.read.bind(file);file.read=(async(...readArgs:unknown[])=>{const buffer=readArgs[0] as Buffer;buffer.fill(120);return {bytesRead:buffer.length,buffer};}) as typeof original;
+ }
+ if(!String(args[0]).includes(".stage-") && control.mutateSource){const original=file.stat.bind(file);let calls=0;file.stat=(async(options:unknown)=>{const value=await original(options as {bigint:true});if(++calls===2)value.ctimeNs+=1n;return value;}) as typeof file.stat;}
+ return file;
+ }};
+});
+import {stageTrainingAuth} from "./trainingAuth.js";
+const roots:string[]=[];
+afterEach(async()=>{control.failWrite=false;control.mutateSource=false;control.growSource=false;for(const root of roots.splice(0))await rm(root,{recursive:true,force:true});});
+const setup=async()=>{const root=await realpath(await mkdtemp(path.join(os.tmpdir(),"training-auth-failure-")));roots.push(root);const home=path.join(root,"home"),source=path.join(root,"source");await mkdir(home,{mode:0o700});await writeFile(source,"complete-fake-credential");return {home,source,provider:"codex" as const};};
+it("never publishes a partial credential and allows a clean retry after write failure",async()=>{
+ const f=await setup();control.failWrite=true;await expect(stageTrainingAuth(f)).rejects.toThrow("disk full");expect(await readdir(path.join(f.home,".daimon-inbound"))).toEqual([]);
+ control.failWrite=false;const receipt=await stageTrainingAuth(f);expect(await readFile(receipt.destination,"utf8")).toBe("complete-fake-credential");
+});
+it("rejects even a nanosecond source identity change before publication",async()=>{
+ const f=await setup();control.mutateSource=true;await expect(stageTrainingAuth(f)).rejects.toThrow("changed during staging");expect(await readdir(path.join(f.home,".daimon-inbound"))).toEqual([]);
+});
+
+it("bounds an unexpectedly growing source to the preallocated limit",async()=>{
+ const f=await setup();control.growSource=true;await expect(stageTrainingAuth(f)).rejects.toThrow("changed during staging");expect(await readdir(path.join(f.home,".daimon-inbound"))).toEqual([]);
+});
+it("selects the fixed default ingress without opening real credentials",async()=>{
+ const f=await setup();await expect(stageTrainingAuth({home:f.home,provider:"codex"})).rejects.toThrow("fixed ingress intercepted for test: /run/paideia-auth/codex");
+});
diff --git a/src/auth/trainingAuth.fifo.test.ts b/src/auth/trainingAuth.fifo.test.ts
new file mode 100644
index 0000000..bf83580
--- /dev/null
+++ b/src/auth/trainingAuth.fifo.test.ts
@@ -0,0 +1,18 @@
+import { execFile } from "node:child_process";
+import { mkdtemp, mkdir, realpath, rm } from "node:fs/promises";
+import path from "node:path";
+import os from "node:os";
+import { promisify } from "node:util";
+import { expect, it } from "vitest";
+const execute = promisify(execFile);
+it("rejects a FIFO before any blocking read in a bounded child", async () => {
+ const root = await realpath(await mkdtemp(path.join(os.tmpdir(), "training-auth-fifo-")));
+ try {
+ const source = path.join(root, "fifo"), home = path.join(root, "home");
+ await mkdir(home, { mode: 0o700 }); await execute("mkfifo", [source]);
+ const module = new URL("./trainingAuth.ts", import.meta.url).href;
+ const script = `const {stageTrainingAuth}=await import(${JSON.stringify(module)});try{await stageTrainingAuth(${JSON.stringify({home,source,provider:"codex"})});throw Error("unexpected stage")}catch(error){if(!String(error).includes("bounded nonempty regular leaf"))throw error;console.log("FIFO_REJECTED_WITHOUT_BLOCKING")}`;
+ const result = await execute(process.execPath, ["--experimental-strip-types", "--input-type=module", "-e", script], { timeout: 2500 });
+ expect(result.stdout.trim()).toBe("FIFO_REJECTED_WITHOUT_BLOCKING");
+ } finally { await rm(root, { recursive: true, force: true }); }
+});
diff --git a/src/auth/trainingAuth.test.ts b/src/auth/trainingAuth.test.ts
new file mode 100644
index 0000000..c72893c
--- /dev/null
+++ b/src/auth/trainingAuth.test.ts
@@ -0,0 +1,22 @@
+import { chmod, mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, writeFile } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, expect, it } from "vitest";
+import { stageTrainingAuth, type TrainingAuthProvider } from "./trainingAuth.js";
+const roots: string[] = [];
+afterEach(async () => { for (const root of roots.splice(0)) await rm(root,{recursive:true,force:true}); });
+const setup = async () => { const root = await realpath(await mkdtemp(path.join(os.tmpdir(),"spawnfile-training-auth-")));roots.push(root);const home=path.join(root,"home"),source=path.join(root,"auth");await mkdir(home,{mode:0o700});await writeFile(source,"opaque-fixture-only");return {root,home,source}; };
+it.each(["codex","grok","claude"] as const)("stages only %s auth to its native private leaf without overwriting renewal",async(provider)=>{
+ const f=await setup();const receipt=await stageTrainingAuth({...f,provider});
+ expect(receipt.version).toBe("spawnfile.training-auth-stage.v1");expect(await readFile(receipt.destination,"utf8")).toBe("opaque-fixture-only");expect((await stat(receipt.destination)).mode&0o777).toBe(0o600);
+ await writeFile(receipt.destination,"renewed");await expect(stageTrainingAuth({...f,provider})).rejects.toThrow("renewed credential preserved");expect(await readFile(receipt.destination,"utf8")).toBe("renewed");
+});
+it("rejects source and destination symlinks, nonprivate ingress, directories and empty/oversized leaves",async()=>{
+ const f=await setup(),alias=path.join(f.root,"alias");await symlink(f.source,alias);
+ for(const source of [alias,f.home]) await expect(stageTrainingAuth({...f,source,provider:"codex"})).rejects.toThrow();
+ for(const bytes of ["","x".repeat(1024*1024+1)]){await writeFile(f.source,bytes);await expect(stageTrainingAuth({...f,provider:"codex"})).rejects.toThrow();}
+ await writeFile(f.source,"fixture");await symlink(f.home,path.join(f.home,".daimon-inbound"));await expect(stageTrainingAuth({...f,provider:"codex"})).rejects.toThrow("private and canonical");
+ await rm(path.join(f.home,".daimon-inbound"));await mkdir(path.join(f.home,".daimon-inbound"));await chmod(path.join(f.home,".daimon-inbound"),0o755);await expect(stageTrainingAuth({...f,provider:"codex"})).rejects.toThrow("private");
+ await expect(stageTrainingAuth({...f,provider:"other" as TrainingAuthProvider})).rejects.toThrow("Unsupported");
+ await expect(stageTrainingAuth({...f,home:"relative",provider:"codex"})).rejects.toThrow("canonical");
+});
diff --git a/src/auth/trainingAuth.ts b/src/auth/trainingAuth.ts
new file mode 100644
index 0000000..cef4396
--- /dev/null
+++ b/src/auth/trainingAuth.ts
@@ -0,0 +1,66 @@
+import { randomUUID } from "node:crypto";
+import { constants } from "node:fs";
+import { link, lstat, mkdir, open, realpath, unlink } from "node:fs/promises";
+import path from "node:path";
+
+export type TrainingAuthProvider = "codex" | "grok" | "claude";
+export interface TrainingAuthStageOptions {
+ /** Caller-provisioned, existing private runtime home; never a host home mount. */
+ home: string;
+ provider: TrainingAuthProvider;
+ /** Explicit provisioned leaf; default is the fixed training ingress. */
+ source?: string;
+}
+const targets: Record = {
+ codex: [".daimon-inbound", "codex-auth"],
+ grok: [".grok", "auth.json"],
+ claude: [".claude", ".credentials.json"]
+};
+/** Stages opaque credential bytes once. Never imports configuration, logs bytes, or overwrites renewed auth. */
+export const stageTrainingAuth = async (options: TrainingAuthStageOptions): Promise<{ version: "spawnfile.training-auth-stage.v1"; provider: TrainingAuthProvider; destination: string }> => {
+ if (!Object.hasOwn(targets, options.provider)) throw Error("Unsupported training auth provider");
+ const home = path.resolve(options.home), source = options.source ?? `/run/paideia-auth/${options.provider}`;
+ if (!path.isAbsolute(options.home) || await realpath(home) !== home || !(await lstat(home)).isDirectory()) throw Error("Training home must be a canonical existing directory");
+ if (!path.isAbsolute(source) || await realpath(source) !== path.resolve(source)) throw Error("Training auth source must be a canonical regular leaf");
+ const input = await open(source, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
+ try {
+ const before = await input.stat({ bigint: true });
+ if (!before.isFile() || before.size < 1n || before.size > 1_048_576n) throw Error("Training auth source must be a bounded nonempty regular leaf");
+ const [folder, leaf] = targets[options.provider];
+ const directory = path.join(home, folder);
+ try { await mkdir(directory, { mode: 0o700 }); }
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
+ const existing = await lstat(directory);
+ if (!existing.isDirectory() || existing.isSymbolicLink() || existing.mode % 512 !== 0o700 || await realpath(directory) !== directory) throw Error("Training auth directory must be private and canonical");
+ const bytes = Buffer.alloc(Number(before.size) + 1);
+ let primary: unknown;
+ const destination = path.join(directory, leaf), temporary = path.join(directory, `.stage-${randomUUID()}`);
+ try {
+ let length = 0;
+ while (length < bytes.length) {
+ const read = await input.read(bytes, length, bytes.length - length, null);
+ if (read.bytesRead === 0) break;
+ length += read.bytesRead;
+ }
+ const after = await input.stat({ bigint: true });
+ if (BigInt(length) !== before.size || after.size !== before.size || after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs || after.ino !== before.ino || after.dev !== before.dev || after.birthtimeNs !== before.birthtimeNs) throw Error("Training auth source changed during staging");
+ const output = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
+ try { await output.writeFile(bytes.subarray(0, length)); await output.sync(); }
+ finally { await output.close(); }
+ // Exclusive publication preserves renewed credentials and never exposes a partial leaf.
+ try { await link(temporary, destination); }
+ catch (error) {
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") throw Error("Training auth already present; renewed credential preserved");
+ throw error;
+ }
+ } catch (error) { primary = error; throw error; } finally {
+ bytes.fill(0);
+ await unlink(temporary).catch((error: NodeJS.ErrnoException) => {
+ if (error.code === "ENOENT") return;
+ if (primary) throw new AggregateError([primary, error], "Training auth staging failed and temporary cleanup is incomplete");
+ throw error;
+ });
+ }
+ return { version: "spawnfile.training-auth-stage.v1", provider: options.provider, destination };
+ } finally { await input.close(); }
+};
diff --git a/src/cli/AGENTS.md b/src/cli/AGENTS.md
index fa3d793..e145d12 100644
--- a/src/cli/AGENTS.md
+++ b/src/cli/AGENTS.md
@@ -13,6 +13,9 @@ src/cli/
├── composedLifecycleContractSet.ts # Closed machine command/contract inventory
├── evidenceExportHelperCommand.ts # Local helper construction command
├── compileBuildCommands.ts # `compile` and `build` command registration
+├── trainCommand.ts # Canonical agent selection and Paideia CLI option forwarding
+├── paideiaDelegation.ts # Private versioned context handoff, child lifecycle and completion receipts
+├── paideiaSupervisor.ts # Packaged group leader retaining identity until native child/group cleanup
├── lifecycleCommands.ts # Thin lifecycle/compile/build/run/publish/up/down registration composition
├── lifecyclePlanningCommands.ts # Durable lifecycle plan and lookup command registration
├── runPublishCommands.ts # `run` and `publish` command registration
diff --git a/src/cli/paideiaDelegation.test.ts b/src/cli/paideiaDelegation.test.ts
new file mode 100644
index 0000000..891fadd
--- /dev/null
+++ b/src/cli/paideiaDelegation.test.ts
@@ -0,0 +1,186 @@
+import { access, chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import type { TrainingContext } from "../compiler/training/index.js";
+import { delegatePaideiaTraining } from "./paideiaDelegation.js";
+
+const directories: string[] = [];
+afterEach(async () => { vi.restoreAllMocks(); await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); });
+const digest = `sha256:${"1".repeat(64)}`;
+const context: TrainingContext = {
+ version: "spawnfile.training-context.v1", producer: { package: "spawnfile", version: "0.1.17" },
+ project: { root: "/isolated/project", manifest: "/isolated/project/Spawnfile", sourceDigest: digest },
+ agent: { id: "agent:writer", name: "writer", source: "/isolated/project/Spawnfile", runtime: "daimon", engine: null, model: null },
+ sources: [{ sourcePath: "/isolated/project/Spawnfile", destinationPath: "Spawnfile", sha256: digest }],
+ documents: [], skills: [], resources: [], requirements: { nativeCompilation: true, isolatedPreparation: true }
+};
+const dryReceipt = 'console.log(JSON.stringify({schema:"paideia.training-cost-plan.v1",modelCallsMade:0}));';
+const completed = 'console.log(JSON.stringify({status:"completed",index:"/isolated/invocation.json"}));';
+
+async function command(body: string): Promise {
+ const directory = await mkdtemp(path.join(os.tmpdir(), "spawnfile-paideia-child-"));
+ directories.push(directory);
+ const executable = path.join(directory, "paideia");
+ // Generated process fixture, not an alternate maintained implementation.
+ await writeFile(executable, `#!${process.execPath}\n${body}\n`);
+ await chmod(executable, 0o700);
+ return executable;
+}
+
+async function invoke(body: string, overrides: Partial[0]> = {}) {
+ const stdout: string[] = [], stderr: string[] = [];
+ const result = delegatePaideiaTraining({ context, command: await command(body), args: ["--dry-run"], dryRun: true,
+ timeoutMs: 5000, streams: { stdout: (line) => stdout.push(line), stderr: (line) => stderr.push(line) }, ...overrides });
+ return { result, stdout, stderr };
+}
+
+describe("Paideia public CLI delegation", () => {
+ it("passes literal argv and a private context, forwards streams and removes the temporary handoff", async () => {
+ const injected = "`touch /tmp/spawnfile-must-not-execute` $(false) with spaces";
+ const { result, stdout, stderr } = await invoke(`
+const fs = require("node:fs");
+const args = process.argv.slice(2), file = args[2];
+console.log(JSON.stringify({args,context:JSON.parse(fs.readFileSync(file,"utf8")),mode:fs.statSync(file).mode & 511,file}));
+process.stderr.write("diagnostic without newline");
+${dryReceipt}`, { args: ["--train", injected, "--editable", "a.md", "--editable", "b.md",
+ "--judge-citation-repairs", "editor=1", "--judge-citation-repairs", injected, "--dry-run"] });
+ expect(await result).toBe(0);
+ const observed = JSON.parse(stdout[0]!);
+ expect(observed.args.slice(0, 2)).toEqual(["train", "--spawnfile-context"]);
+ expect(observed.args.slice(3)).toEqual(["--train", injected, "--editable", "a.md", "--editable", "b.md",
+ "--judge-citation-repairs", "editor=1", "--judge-citation-repairs", injected, "--dry-run"]);
+ expect(observed.context).toEqual(context);
+ expect(observed.mode).toBe(0o600);
+ expect(stderr).toEqual(["diagnostic without newline"]);
+ await expect(access(observed.file)).rejects.toThrow();
+ });
+
+ it.each([
+ "", 'console.log(" ");', 'console.log("not json");', 'console.log("null");',
+ 'console.log(JSON.stringify({schema:"paideia.training-cost-plan.v1",modelCallsMade:1}));',
+ `${dryReceipt} console.log("done");`
+ ])("rejects empty, malformed or nonfinal success receipts: %s", async (body) => {
+ const run = await invoke(body);
+ await expect(run.result).rejects.toThrow("required final training receipt");
+ });
+
+ it("handles a receipt without a newline and ordinary blank lines", async () => {
+ const run = await invoke('process.stdout.write("\\n\\r\\n" + JSON.stringify({schema:"paideia.training-cost-plan.v1",modelCallsMade:0}));');
+ expect(await run.result).toBe(0);
+ });
+
+ it("rejects every host actual-training path before invoking a model executable", async () => {
+ for (const body of [completed, dryReceipt, 'console.log("unexpected");']) {
+ const run = await invoke(body, { dryRun: false, args: [] });
+ await expect(run.result).rejects.toThrow("host execution is disabled");
+ expect(run.stdout).toEqual([]);
+ }
+ });
+
+ it("propagates receiver errors without requiring a success receipt", async () => {
+ const run = await invoke('console.error("unsupported native preparation"); process.exitCode=2;');
+ expect(await run.result).toBe(2);
+ expect(run.stderr).toEqual(["unsupported native preparation"]);
+ });
+
+ it("reports missing executables without fallback", async () => {
+ const run = await invoke(dryReceipt, { command: "/does-not-exist/paideia" });
+ await expect(run.result).rejects.toThrow("Could not start Paideia");
+ });
+
+ it("cancels a running child and cleans up the context", async () => {
+ const controller = new AbortController();
+ let contextPath = "";
+ const run = await invoke('console.log(process.argv[4]); setInterval(()=>{},100);', {
+ signal: controller.signal, streams: { stdout: (line) => { contextPath = line; controller.abort(); }, stderr: () => undefined }
+ });
+ expect(await run.result).toBe(130);
+ await expect(access(contextPath)).rejects.toThrow();
+ });
+
+ it("does not spawn when already cancelled", async () => {
+ const controller = new AbortController(); controller.abort();
+ const run = await invoke("throw Error('must not run');", { signal: controller.signal });
+ expect(await run.result).toBe(130);
+ expect(run.stdout).toEqual([]);
+ });
+
+ it.each([["SIGINT", 130], ["SIGTERM", 143]] as const)("forwards parent %s and removes its signal handler", async (signal, expected) => {
+ const registered = vi.spyOn(process, "once");
+ let listener: (() => void) | undefined;
+ const run = await invoke('console.log("ready"); setInterval(()=>{},100);', {
+ streams: { stdout: () => {
+ listener = registered.mock.calls.findLast(([name]) => name === signal)?.[1] as (() => void) | undefined;
+ expect(listener).toBeTypeOf("function"); listener!();
+ }, stderr: () => undefined }
+ });
+ expect(await run.result).toBe(expected);
+ expect(process.listeners(signal)).not.toContain(listener);
+ });
+
+ it("force-stops a child that ignores graceful termination", async () => {
+ const controller = new AbortController();
+ const run = await invoke('process.on("SIGTERM",()=>{}); console.log("ready"); setInterval(()=>{},100);', {
+ signal: controller.signal, streams: { stdout: () => controller.abort(), stderr: () => undefined }
+ });
+ expect(await run.result).toBe(130);
+ });
+
+ it.each([true, false])("terminates descendants with inherited output=%s after the native leader exits on cancellation", async (inherited) => {
+ await descendantTrial(inherited, true);
+ });
+
+ it.each([true, false])("confirms supervisor and descendant quiescence before success with inherited output=%s", async (inherited) => {
+ await descendantTrial(inherited, false);
+ });
+
+ it("reports unknown group cleanup as an error and never signals after the supervisor is reaped", async () => {
+ const original = process.kill.bind(process);
+ const kill = vi.spyOn(process, "kill").mockImplementation((pid, signal) => signal === 0 ? true : original(pid, signal));
+ const run = await invoke(dryReceipt);
+ await expect(run.result).rejects.toThrow("quiescence is unknown");
+ const firstProbe = kill.mock.calls.findIndex(([, signal]) => signal === 0);
+ expect(firstProbe).toBeGreaterThan(-1);
+ expect(kill.mock.calls.slice(firstProbe).every(([, signal]) => signal === 0)).toBe(true);
+ });
+
+ it("bounds hung children and oversized output", async () => {
+ const hung = await invoke('setInterval(()=>{},100);', { timeoutMs: 100 });
+ await expect(hung.result).rejects.toThrow("command deadline");
+ const noisy = await invoke('process.stdout.write("x".repeat(1024*1024+1)); setInterval(()=>{},100);');
+ await expect(noisy.result).rejects.toThrow("bounded JSON-line contract");
+ });
+
+ it("preserves a child signal outcome", async () => {
+ const run = await invoke('process.kill(process.pid,"SIGTERM");');
+ expect(await run.result).toBe(143);
+ });
+
+ it("rejects an oversized context before spawning", async () => {
+ const run = await invoke("throw Error('must not run');", { context: { ...context,
+ producer: { package: "spawnfile", version: "v".repeat(1024 * 1024) } } });
+ await expect(run.result).rejects.toThrow("context exceeds 1 MiB");
+ });
+});
+
+async function descendantTrial(inherited: boolean, cancel: boolean): Promise {
+ const folder = await mkdtemp(path.join(os.tmpdir(), "spawnfile-training-descendant-")); directories.push(folder);
+ const ready = path.join(folder, "ready");
+ const descendant = 'process.on("SIGTERM",()=>{}); require("node:fs").writeFileSync(' + JSON.stringify(ready) + ',"ready");setInterval(()=>{},100);';
+ const leader = 'const {spawn}=require("node:child_process"),fs=require("node:fs");process.on("SIGTERM",()=>process.exit(0));'
+ + `const descendant=spawn(process.execPath,["-e",${JSON.stringify(descendant)}],{stdio:${JSON.stringify(inherited ? ["ignore", "inherit", "inherit"] : "ignore")}});`
+ + `const timer=setInterval(()=>{if(!fs.existsSync(${JSON.stringify(ready)}))return;clearInterval(timer);`
+ + 'console.log(JSON.stringify({leader:process.pid,supervisor:process.ppid,descendant:descendant.pid}));'
+ + (cancel ? 'setInterval(()=>{},100);' : `${dryReceipt} process.exit(0);`) + '},10);';
+ const controller = new AbortController();
+ let pids: { leader: number; supervisor: number; descendant: number } | undefined;
+ const run = await invoke(leader, { signal: controller.signal, streams: { stdout: (line) => {
+ const value = JSON.parse(line);
+ if (typeof value.descendant === "number") { pids = value; if (cancel) controller.abort(); }
+ }, stderr: () => undefined } });
+ expect(await run.result).toBe(cancel ? 130 : 0);
+ expect(pids).toBeDefined();
+ for (const pid of Object.values(pids!)) expect(() => process.kill(pid, 0)).toThrow(/ESRCH/u);
+}
diff --git a/src/cli/paideiaDelegation.ts b/src/cli/paideiaDelegation.ts
new file mode 100644
index 0000000..b9f4c09
--- /dev/null
+++ b/src/cli/paideiaDelegation.ts
@@ -0,0 +1,161 @@
+import { spawn } from "node:child_process";
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { setTimeout as delay } from "node:timers/promises";
+import { fileURLToPath } from "node:url";
+
+import { trainingContextSchema, type TrainingContext } from "../compiler/training/index.js";
+import { launchTrainingContainer } from "../compiler/training/container/index.js";
+import { runTrainingDocker } from "../compiler/training/container/process.js";
+import { prepareTraining } from "../compiler/training/preparation/index.js";
+import { readBoundedJson } from "../compiler/training/preparation/inputs.js";
+import { SpawnfileError } from "../shared/index.js";
+import type { PaideiaProcessOutcome } from "./paideiaSupervisor.js";
+import type { CliStreams } from "./runCli.js";
+
+export interface DelegatePaideiaTrainingOptions {
+ context: TrainingContext;
+ command: string;
+ args: readonly string[];
+ dryRun: boolean;
+ trainingImage?: string;
+ trainingConfig?: string;
+ timeoutMs: number;
+ streams: CliStreams;
+ signal?: AbortSignal;
+}
+
+const MAX_LINE_BYTES = 1024 * 1024;
+const validReceipt = (line: string, dryRun: boolean): boolean => {
+ try {
+ const value: unknown = JSON.parse(line);
+ if (typeof value !== "object" || value === null) return false;
+ const receipt = value as Record;
+ return dryRun
+ ? receipt.schema === "paideia.training-cost-plan.v1" && receipt.modelCallsMade === 0
+ : receipt.status === "completed" && typeof receipt.index === "string" && receipt.index.length > 0;
+ } catch { return false; }
+};
+const failure = (message: string): SpawnfileError => new SpawnfileError("runtime_error", message);
+
+const runChild = (options: DelegatePaideiaTrainingOptions, contextPath: string): Promise => new Promise((resolve, reject) => {
+ if (options.signal?.aborted) { resolve(130); return; }
+ // Node strips types in a source checkout; the packaged build selects its emitted JS.
+ const extension = path.extname(fileURLToPath(import.meta.url));
+ const supervisor = fileURLToPath(new URL(`./paideiaSupervisor${extension}`, import.meta.url));
+ const child = spawn(process.execPath, ["--experimental-strip-types", supervisor, options.command,
+ "train", "--spawnfile-context", contextPath, ...options.args], {
+ shell: false, detached: true, stdio: ["ignore", "pipe", "pipe", "ipc"]
+ });
+ const pid = child.pid;
+ let stdout = "", stderr = "", lastLine = "";
+ let stopCode: number | undefined, error: Error | undefined, outcome: PaideiaProcessOutcome | undefined;
+ let reaped = false, closed = false, finished = false;
+ let killTimer: ReturnType | undefined;
+ const kill = (signal: NodeJS.Signals): void => {
+ if (pid === undefined || reaped) return;
+ try { process.kill(-pid, signal); }
+ catch (caught) { if ((caught as NodeJS.ErrnoException).code !== "ESRCH") error = failure("Could not signal the owned Paideia process group"); }
+ };
+ const groupExists = (): boolean => {
+ if (pid === undefined) return false;
+ try { process.kill(-pid, 0); return true; }
+ catch (caught) { return (caught as NodeJS.ErrnoException).code !== "ESRCH"; }
+ };
+ const stop = (code: number, cause?: Error): void => {
+ if (stopCode !== undefined) return;
+ stopCode = code; error = cause;
+ kill("SIGTERM");
+ killTimer = setTimeout(() => kill("SIGKILL"), 1000);
+ killTimer.unref();
+ };
+ const interrupt = (): void => stop(130), terminate = (): void => stop(143), abort = (): void => stop(130);
+ const timer = setTimeout(() => stop(1, failure("Paideia training exceeded its command deadline")), options.timeoutMs);
+ timer.unref();
+ process.once("SIGINT", interrupt); process.once("SIGTERM", terminate);
+ options.signal?.addEventListener("abort", abort, { once: true });
+ if (options.signal?.aborted) abort();
+
+ const consume = (chunk: string, channel: "stdout" | "stderr"): void => {
+ const lines = ((channel === "stdout" ? stdout : stderr) + chunk).split("\n");
+ const remainder = lines.pop()!;
+ if ([remainder, ...lines].some((line) => Buffer.byteLength(line, "utf8") > MAX_LINE_BYTES)) {
+ stop(1, failure("Paideia output exceeded the bounded JSON-line contract")); return;
+ }
+ for (const raw of lines) {
+ const line = raw.replace(/\r$/u, "");
+ if (!line.trim()) continue;
+ if (channel === "stdout") lastLine = line;
+ options.streams[channel](line);
+ }
+ if (channel === "stdout") stdout = remainder; else stderr = remainder;
+ };
+ child.stdout!.setEncoding("utf8").on("data", (chunk: string) => consume(chunk, "stdout"));
+ child.stderr!.setEncoding("utf8").on("data", (chunk: string) => consume(chunk, "stderr"));
+ child.once("message", (message: PaideiaProcessOutcome) => {
+ outcome = message;
+ if (message.type === "paideia.process.launch-error") error = failure(`Could not start Paideia: ${message.message}`);
+ // The native child exited, but our supervisor still holds the group identity.
+ // Stop stragglers before permitting its leader to disappear or reporting completion.
+ kill("SIGKILL");
+ });
+ const finish = async (): Promise => {
+ if (finished) return;
+ finished = true;
+ clearTimeout(timer); clearTimeout(killTimer);
+ process.removeListener("SIGINT", interrupt); process.removeListener("SIGTERM", terminate);
+ options.signal?.removeEventListener("abort", abort);
+ const deadline = Date.now() + 1500;
+ // Check only: never signal a numeric group identity after its supervisor was reaped.
+ while ((groupExists() || !closed) && Date.now() < deadline) await delay(20);
+ if (groupExists() || !closed) {
+ error = failure("Paideia process cleanup is incomplete; group or output quiescence is unknown");
+ child.stdout!.destroy(); child.stderr!.destroy();
+ }
+ if (stdout.trim()) { lastLine = stdout; options.streams.stdout(stdout); }
+ if (stderr.trim()) options.streams.stderr(stderr);
+ if (error) { reject(error); return; }
+ if (stopCode !== undefined) { resolve(stopCode); return; }
+ if (outcome?.type !== "paideia.process.exited") { reject(failure("Paideia supervisor exited without a native outcome")); return; }
+ if (outcome.signal) { resolve(outcome.signal === "SIGINT" ? 130 : outcome.signal === "SIGTERM" ? 143 : 1); return; }
+ const exitCode = outcome.code ?? 1;
+ if ((exitCode === 0 || exitCode === 1) && !validReceipt(lastLine, options.dryRun)) {
+ reject(failure("Paideia exited without the required final training receipt")); return;
+ }
+ resolve(exitCode);
+ };
+ child.once("error", (caught) => { error = failure(`Could not start Paideia supervisor: ${caught.message}`); });
+ child.once("exit", () => { reaped = true; void finish(); });
+ child.once("close", () => { closed = true; void finish(); });
+});
+
+/** Delegates through the public CLI, never importing Paideia or selecting a fallback adapter. */
+export const delegatePaideiaTraining = async (options: DelegatePaideiaTrainingOptions): Promise => {
+ if (options.signal?.aborted) return 130;
+ if (options.trainingConfig) {
+ const config = await readBoundedJson(options.trainingConfig) as { version?: unknown };
+ if (config.version === "spawnfile.training-container.v2") {
+ if (options.trainingImage) throw failure("V2 owns its image declaration; --training-image is only for v1");
+ const prepared = await prepareTraining({ configPath: options.trainingConfig, context: options.context, args: options.args,
+ dryRun: options.dryRun, process: runTrainingDocker, timeoutMs: options.timeoutMs, signal: options.signal, streams: options.streams });
+ if (!("dryRun" in prepared)) return launchTrainingContainer({ ...prepared,
+ timeoutMs: options.timeoutMs, signal: options.signal, streams: options.streams });
+ options.streams.stderr(`Training preparation plan ${prepared.digest}; no Docker, auth or filesystem mutations`);
+ }
+ }
+ if (!options.dryRun) {
+ if (!options.trainingImage || !options.trainingConfig) throw failure("Actual training requires --training-image and --training-config; host execution is disabled");
+ return launchTrainingContainer({ image: options.trainingImage, configPath: options.trainingConfig,
+ context: options.context, args: options.args, timeoutMs: options.timeoutMs, streams: options.streams, signal: options.signal });
+ }
+ if (process.platform === "win32") throw failure("Paideia delegation requires POSIX process-group supervision");
+ const bytes = JSON.stringify(trainingContextSchema.parse(options.context));
+ if (Buffer.byteLength(bytes, "utf8") > MAX_LINE_BYTES) throw new SpawnfileError("validation_error", "Training context exceeds 1 MiB");
+ const temporary = await mkdtemp(path.join(os.tmpdir(), "spawnfile-training-"));
+ const contextPath = path.join(temporary, "context.json");
+ try {
+ await writeFile(contextPath, bytes, { mode: 0o600, flag: "wx" });
+ return await runChild(options, contextPath);
+ } finally { await rm(temporary, { recursive: true, force: true }); }
+};
diff --git a/src/cli/paideiaSupervisor.test.ts b/src/cli/paideiaSupervisor.test.ts
new file mode 100644
index 0000000..e8a1478
--- /dev/null
+++ b/src/cli/paideiaSupervisor.test.ts
@@ -0,0 +1,49 @@
+import { EventEmitter } from "node:events";
+import type { ChildProcess } from "node:child_process";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { supervisePaideia, type PaideiaSupervisorHost } from "./paideiaSupervisor.js";
+
+const cleanups: Array<() => void> = [];
+afterEach(() => { cleanups.splice(0).forEach((cleanup) => cleanup()); });
+function fixture() {
+ const process = Object.assign(new EventEmitter(), { pid: 1234, send: vi.fn(), kill: vi.fn(), exit: vi.fn() });
+ const child = new EventEmitter();
+ const launch = vi.fn(() => child as ChildProcess);
+ const host = { process, launch } as unknown as PaideiaSupervisorHost;
+ return { process, child, launch, host };
+}
+
+describe("Paideia group supervisor", () => {
+ it("passes argv unchanged and reports the real child outcome once while retaining group ownership", () => {
+ const run = fixture();
+ cleanups.push(supervisePaideia(["paideia", "train", "literal $argument"], run.host));
+ expect(run.launch).toHaveBeenCalledWith("paideia", ["train", "literal $argument"]);
+ run.child.emit("exit", 1, null); run.child.emit("error", new Error("later"));
+ expect(run.process.send).toHaveBeenCalledExactlyOnceWith({ type: "paideia.process.exited", code: 1, signal: null });
+ expect(run.process.exit).not.toHaveBeenCalled();
+ run.process.emit("SIGTERM"); run.process.emit("SIGINT");
+ expect(run.process.exit).not.toHaveBeenCalled();
+ });
+
+ it("reports launch failures without inventing completion", () => {
+ const run = fixture(); cleanups.push(supervisePaideia(["missing"], run.host));
+ run.child.emit("error", new Error("ENOENT"));
+ expect(run.process.send).toHaveBeenCalledWith({ type: "paideia.process.launch-error", message: "ENOENT" });
+ });
+
+ it("cleans its own still-live group if its parent disappears", () => {
+ const run = fixture(); cleanups.push(supervisePaideia(["paideia"], run.host));
+ run.process.emit("disconnect");
+ expect(run.process.kill).toHaveBeenCalledWith(-1234, "SIGKILL");
+ expect(run.process.exit).toHaveBeenCalledWith(1);
+ });
+
+ it("requires a private parent channel and removes owned listeners on disposal", () => {
+ const run = fixture();
+ expect(() => supervisePaideia([], run.host)).toThrow("private IPC");
+ expect(() => supervisePaideia(["paideia"], { ...run.host, process: { ...run.host.process, send: undefined } })).toThrow("private IPC");
+ const cleanup = supervisePaideia(["paideia"], run.host); cleanup();
+ expect(run.process.listenerCount("disconnect")).toBe(0);
+ });
+});
diff --git a/src/cli/paideiaSupervisor.ts b/src/cli/paideiaSupervisor.ts
new file mode 100644
index 0000000..9b5fb93
--- /dev/null
+++ b/src/cli/paideiaSupervisor.ts
@@ -0,0 +1,49 @@
+import { spawn, type ChildProcess } from "node:child_process";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+export type PaideiaProcessOutcome =
+ | { type: "paideia.process.exited"; code: number | null; signal: NodeJS.Signals | null }
+ | { type: "paideia.process.launch-error"; message: string };
+
+type SupervisorProcess = Pick;
+export interface PaideiaSupervisorHost {
+ process: SupervisorProcess;
+ launch(command: string, args: string[]): ChildProcess;
+}
+const defaultHost: PaideiaSupervisorHost = {
+ process,
+ launch: (command, args) => spawn(command, args, { shell: false, stdio: ["ignore", "inherit", "inherit"] })
+};
+
+/** Remains the owned group leader until the parent closes the entire group. */
+export const supervisePaideia = (argv: readonly string[], host: PaideiaSupervisorHost = defaultHost): (() => void) => {
+ const [command, ...args] = argv;
+ if (!command || !host.process.send) throw new Error("Paideia supervisor requires an executable and private IPC");
+ const hold = setInterval(() => undefined, 1000);
+ const ignore = (): void => undefined;
+ const disconnect = (): void => {
+ // This process is still alive: its own group identity cannot have been recycled.
+ try { host.process.kill(-host.process.pid, "SIGKILL"); }
+ finally { host.process.exit(1); }
+ };
+ host.process.on("SIGINT", ignore).on("SIGTERM", ignore).on("disconnect", disconnect);
+ let reported = false;
+ const report = (outcome: PaideiaProcessOutcome): void => {
+ if (reported) return;
+ reported = true;
+ host.process.send!(outcome);
+ };
+ const child = host.launch(command, args);
+ child.once("error", (error) => report({ type: "paideia.process.launch-error", message: error.message }));
+ child.once("exit", (code, signal) => report({ type: "paideia.process.exited", code, signal }));
+ return () => {
+ clearInterval(hold);
+ host.process.removeListener("SIGINT", ignore).removeListener("SIGTERM", ignore).removeListener("disconnect", disconnect);
+ };
+};
+
+if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ try { supervisePaideia(process.argv.slice(2)); }
+ catch (error) { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); process.exitCode = 1; }
+}
diff --git a/src/cli/runCli.ts b/src/cli/runCli.ts
index 82dcdc9..9da2c18 100644
--- a/src/cli/runCli.ts
+++ b/src/cli/runCli.ts
@@ -22,6 +22,7 @@ import {
buildUpReceipt,
clearProjectModelFallbacks,
compileProject,
+ createTrainingContext,
initProject,
listInitTemplates,
publishProject,
@@ -65,6 +66,8 @@ import { registerStatusCommand } from "./statusCommand.js";
import { registerUsageCommand } from "./usageCommand.js";
import { registerProductionTargetCommands } from "./targetProductionCommands.js";
import { registerViewCommand } from "./viewCommand.js";
+import { registerTrainCommand } from "./trainCommand.js";
+import { delegatePaideiaTraining } from "./paideiaDelegation.js";
const packageJsonPath = new URL("../../package.json", import.meta.url);
@@ -95,6 +98,8 @@ const createDefaultRenderEnvironment = (): CliRenderEnvironment => ({
});
export interface CliHandlers {
+ createTrainingContext: typeof createTrainingContext;
+ delegatePaideiaTraining: typeof delegatePaideiaTraining;
buildCompilePlan: typeof buildCompilePlan; buildOrganizationView: typeof buildOrganizationView;
buildProject: typeof buildProject; compileProject: typeof compileProject;
publishProject: typeof publishProject;
@@ -127,6 +132,7 @@ export interface CliHandlers {
}
const createDefaultHandlers = (): CliHandlers => ({
+ createTrainingContext, delegatePaideiaTraining,
buildCompilePlan, buildOrganizationView, buildProject, compileProject, publishProject,
addAgentProject, addProjectModelFallback, addProjectSurface,
addSubagentProject, addTeamProject, clearProjectModelFallbacks,
@@ -141,7 +147,7 @@ const createDefaultHandlers = (): CliHandlers => ({
});
export interface RunCliOptions {
- handlers?: Partial; renderEnvironment?: CliRenderEnvironment; stdin?: AsyncIterable; streams?: CliStreams;
+ handlers?: Partial; renderEnvironment?: CliRenderEnvironment; stdin?: AsyncIterable; streams?: CliStreams; signal?: AbortSignal;
}
const isCliStreams = (value: CliStreams | RunCliOptions | undefined): value is CliStreams => {
@@ -152,7 +158,7 @@ const isCliStreams = (value: CliStreams | RunCliOptions | undefined): value is C
const normalizeRunCliOptions = (
optionsOrStreams?: CliStreams | RunCliOptions,
handlerOverrides: Partial = {}
-): Required => isCliStreams(optionsOrStreams)
+): Required> & Pick => isCliStreams(optionsOrStreams)
? {
handlers: handlerOverrides,
renderEnvironment: createDefaultRenderEnvironment(),
@@ -163,7 +169,8 @@ const normalizeRunCliOptions = (
handlers: optionsOrStreams?.handlers ?? handlerOverrides,
renderEnvironment: optionsOrStreams?.renderEnvironment ?? createDefaultRenderEnvironment(),
stdin: optionsOrStreams?.stdin ?? process.stdin,
- streams: optionsOrStreams?.streams ?? createDefaultStreams()
+ streams: optionsOrStreams?.streams ?? createDefaultStreams(),
+ signal: optionsOrStreams?.signal
};
const writeCommanderOutput = (
@@ -229,7 +236,7 @@ export const runCli: RunCli = async (
const streams = cliOptions.streams;
const handlers = { ...createDefaultHandlers(), ...cliOptions.handlers };
const isTargetInvocation = argv[0] === "target";
- let commandExitCode: 0 | 1 | 2 = 0;
+ let commandExitCode = 0;
const program = new Command();
program.name("spawnfile").description("Spawnfile v0.1 compiler").version(readPackageVersion());
program.exitOverride();
@@ -334,6 +341,9 @@ export const runCli: RunCli = async (
commandExitCode = exitCode;
}, handlers);
registerViewCommand(program, handlers, streams, cliOptions.renderEnvironment);
+ registerTrainCommand(program, handlers, streams, readPackageVersion(), (code) => {
+ commandExitCode = code;
+ }, cliOptions.signal);
registerProductionTargetCommands(program, streams, cliOptions.stdin, (exitCode) => {
commandExitCode = exitCode;
});
diff --git a/src/cli/trainCommand.test.ts b/src/cli/trainCommand.test.ts
new file mode 100644
index 0000000..6f54b9f
--- /dev/null
+++ b/src/cli/trainCommand.test.ts
@@ -0,0 +1,117 @@
+import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { runCli } from "./runCli.js";
+import type { DelegatePaideiaTrainingOptions } from "./paideiaDelegation.js";
+
+const directories: string[] = [];
+afterEach(async () => { await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); });
+async function project(): Promise {
+ const root = await realpath(await mkdtemp(path.join(os.tmpdir(), "spawnfile-train-command-")));
+ directories.push(root);
+ await writeFile(path.join(root, "Spawnfile"), 'spawnfile_version: "0.1"\nkind: agent\nname: author\nruntime: daimon\n');
+ return root;
+}
+const container = ["--training-image", `sha256:${"a".repeat(64)}`, "--training-config", "/training.json"];
+const base = ["--train", "train.paideia.yaml", "--test", "test.paideia.yaml", "--out", "local output"];
+
+describe("spawnfile train", () => {
+ it("resolves the real canonical project and forwards only explicit Paideia options", async () => {
+ const root = await project(), stdout: string[] = [], stderr: string[] = [];
+ const delegate = vi.fn(async (_options: DelegatePaideiaTrainingOptions) => 0);
+ const forbidden = vi.fn(async () => { throw new Error("must not compile, build or authenticate"); });
+ const code = await runCli(["train", root, ...base, "--agent", "agent:author", "--dry-run",
+ "--paideia-command", "/opt/paideia with space", "--editable", "a.md", "--editable", "b.md", "--resource", "archive=/private/source",
+ "--judge", "editor=fable", "--judge", "grounding=other-model", "--judge-citation-repairs", "editor=1",
+ "--judge-citation-repairs", "grounding=0", "--validation-group", "previous", "--cost-config", "prices.yaml", "--max-trials", "3", "--timeout-ms", "5000"], {
+ streams: { stdout: (value) => stdout.push(value), stderr: (value) => stderr.push(value) },
+ handlers: { delegatePaideiaTraining: delegate, compileProject: forbidden, buildProject: forbidden, importCodexAuth: forbidden }
+ });
+ expect(code).toBe(0); expect(forbidden).not.toHaveBeenCalled();
+ expect(delegate).toHaveBeenCalledOnce();
+ const options = delegate.mock.calls[0]![0];
+ expect(options.context.agent.id).toBe("agent:author");
+ expect(options.command).toBe("/opt/paideia with space");
+ expect(options.dryRun).toBe(true); expect(options.timeoutMs).toBe(10_000);
+ expect(options.args).toEqual(["--train", "train.paideia.yaml", "--test", "test.paideia.yaml", "--editable", "a.md", "--editable", "b.md",
+ "--resource", "archive=/private/source", "--judge", "editor=fable", "--judge", "grounding=other-model",
+ "--judge-citation-repairs", "editor=1", "--judge-citation-repairs", "grounding=0", "--validation-group", "previous",
+ "--out", "local output", "--max-trials", "3", "--timeout-ms", "5000", "--cost-config", "prices.yaml", "--dry-run"]);
+ expect(stderr).toEqual([]);
+ });
+
+ it("delegates YAML-owned test selection and permits the complete YAML time budget", async () => {
+ const delegate = vi.fn(async (_options: DelegatePaideiaTrainingOptions) => 0);
+ expect(await runCli(["train", await project(), "--train", "train.paideia.yaml", "--dry-run"], {
+ handlers: { delegatePaideiaTraining: delegate }, streams: { stdout: () => undefined, stderr: () => undefined }
+ })).toBe(0);
+ expect(delegate.mock.calls[0]![0]).toMatchObject({ timeoutMs: 3_605_000, args: ["--train", "train.paideia.yaml", "--dry-run"] });
+ });
+
+ it("forwards explicit resume to Paideia without changing canonical agent context", async () => {
+ const delegate = vi.fn(async (_options: DelegatePaideiaTrainingOptions) => 0);
+ expect(await runCli(["train", await project(), ...base, ...container, "--resume"], {
+ handlers: { delegatePaideiaTraining: delegate }, streams: { stdout: () => undefined, stderr: () => undefined }
+ })).toBe(0);
+ expect(delegate.mock.calls[0]![0].args).toEqual([...base, "--resume"]);
+ expect(delegate.mock.calls[0]![0].context.agent.id).toBe("agent:author");
+ expect(delegate.mock.calls[0]![0].dryRun).toBe(false);
+ });
+
+ it.each([1, 2, 130, 143])("propagates the delegated exit %s", async (exitCode) => {
+ const code = await runCli(["train", await project(), ...base, ...container], { handlers: { delegatePaideiaTraining: async () => exitCode },
+ streams: { stdout: () => undefined, stderr: () => undefined } });
+ expect(code).toBe(exitCode);
+ });
+
+ it("forwards cancellation and default executable/timeout without forcing dry-run", async () => {
+ const controller = new AbortController();
+ let captured: DelegatePaideiaTrainingOptions | undefined;
+ expect(await runCli(["train", await project(), ...base, ...container, "--optimizer-model", "fable", "--bridge-command", "bridge", "--max-proposals", "1", "--seed", "0", "--view", "0"], {
+ signal: controller.signal, handlers: { delegatePaideiaTraining: async (options) => { captured = options; return 2; } },
+ streams: { stdout: () => undefined, stderr: () => undefined }
+ })).toBe(2);
+ expect(captured).toMatchObject({ command: "paideia", timeoutMs: 3_605_000, signal: controller.signal, dryRun: false });
+ expect(captured?.args).toContain("--bridge-command");
+ expect(captured?.args).not.toContain("--dry-run");
+ });
+
+ it("allows dry-run without output or optimizer bridge, but rejects an actual run without output", async () => {
+ const root = await project(), delegated = vi.fn(async () => 0);
+ const args = ["train", root, "--train", "train.paideia.yaml", "--test", "test.paideia.yaml"];
+ const options = { handlers: { delegatePaideiaTraining: delegated }, streams: { stdout: () => undefined, stderr: () => undefined } };
+ expect(await runCli([...args, "--dry-run"], options)).toBe(0);
+ expect(delegated).toHaveBeenCalledOnce();
+ expect(await runCli(args, options)).toBe(2);
+ expect(delegated).toHaveBeenCalledOnce();
+ });
+
+ it.each(["0", "1.5", "-1", "NaN", "3600001"])("rejects timeout %s before extracting or delegating", async (timeout) => {
+ const forbidden = vi.fn(async () => { throw Error("must not start"); });
+ const code = await runCli(["train", "/missing", ...base, "--timeout-ms", timeout], {
+ handlers: { createTrainingContext: forbidden, delegatePaideiaTraining: forbidden }, streams: { stdout: () => undefined, stderr: () => undefined }
+ });
+ expect(code).toBe(2); expect(forbidden).not.toHaveBeenCalled();
+ });
+
+ it("rejects generic runtime/instruction overrides and missing required datasets", async () => {
+ const forbidden = vi.fn(async () => { throw Error("must not start"); });
+ for (const args of [["train", ...base, "--runtime", "pi"], ["train", ...base, "--instructions", "prompt.md"], ["train", "--dry-run"]]) {
+ expect(await runCli(args, { handlers: { delegatePaideiaTraining: forbidden }, streams: { stdout: () => undefined, stderr: () => undefined } })).toBe(2);
+ }
+ expect(forbidden).not.toHaveBeenCalled();
+ });
+ it("forwards literal invalid repair selections for Paideia to validate and preserves receiver failure", async () => {
+ const root = await project();
+ const delegate = vi.fn(async (_options: DelegatePaideiaTrainingOptions) => 2);
+ const literal = "unknown=$(false) with spaces";
+ expect(await runCli(["train", root, ...base, "--judge-citation-repairs", literal,
+ "--judge-citation-repairs", literal, "--dry-run"], {
+ handlers: { delegatePaideiaTraining: delegate }, streams: { stdout: () => undefined, stderr: () => undefined }
+ })).toBe(2);
+ expect(delegate.mock.calls[0]![0].args).toEqual(["--train", "train.paideia.yaml", "--test", "test.paideia.yaml",
+ "--judge-citation-repairs", literal, "--judge-citation-repairs", literal, "--out", "local output", "--dry-run"]);
+ });
+});
diff --git a/src/cli/trainCommand.ts b/src/cli/trainCommand.ts
new file mode 100644
index 0000000..0c857aa
--- /dev/null
+++ b/src/cli/trainCommand.ts
@@ -0,0 +1,62 @@
+import type { Command } from "commander";
+
+import { SpawnfileError } from "../shared/index.js";
+import type { CliHandlers, CliStreams } from "./runCli.js";
+
+const forwarded = ["train", "test", "editable", "resource", "judge", "judge-citation-repairs", "validation-group", "optimizer-model",
+ "bridge-command", "out", "max-trials", "max-proposals", "seed", "timeout-ms", "view", "cost-config"] as const;
+const repeated = new Set(["editable", "resource", "judge", "judge-citation-repairs", "validation-group"]);
+const key = (name: string): string => name.replace(/-([a-z])/gu, (_, letter: string) => letter.toUpperCase());
+
+export const registerTrainCommand = (
+ program: Command,
+ handlers: CliHandlers,
+ streams: CliStreams,
+ packageVersion: string,
+ setExitCode: (code: number) => void,
+ signal?: AbortSignal
+): void => {
+ const command = program.command("train")
+ .description("Train one canonical agent through Paideia's isolated native integration")
+ .argument("[path]", "Canonical project directory or Spawnfile path", process.cwd())
+ .option("--agent ", "Exact canonical agent node id (inferred only for a single-agent project)")
+ .option("--paideia-command ", "Installed Paideia executable; no shell or automatic install", "paideia")
+ .option("--training-image ", "Pinned image containing the complete training environment")
+ .option("--training-config ", "Explicit local Docker inputs, output and auth leaf bindings")
+ .option("--dry-run", "Validate and estimate without compiling, authenticating or starting models")
+ .option("--resume", "Resume the exact persisted training experiment in --out");
+ for (const name of forwarded) {
+ const flag = `--${name} `;
+ if (repeated.has(name)) command.option(flag, `Paideia ${name}; repeatable`, (value: string, previous: string[]) => [...previous, value], []);
+ else if (name === "train") command.requiredOption(flag, `Paideia ${name}`);
+ else command.option(flag, `Paideia ${name}`);
+ }
+ command.action(async (inputPath: string, options: Record) => {
+ if (options.dryRun !== true && typeof options.out !== "string") {
+ throw new SpawnfileError("validation_error", "Actual training requires --out; dry-run does not write an output directory");
+ }
+ if (options.dryRun !== true && typeof options.trainingConfig !== "string") {
+ throw new SpawnfileError("validation_error", "Actual training requires --training-config; v1 additionally requires --training-image; host execution is disabled");
+ }
+ const timeout = options.timeoutMs === undefined ? 3_600_000 : Number(options.timeoutMs);
+ if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 3_600_000 ||
+ (options.timeoutMs !== undefined && !/^\d+$/u.test(String(options.timeoutMs)))) {
+ throw new SpawnfileError("validation_error", "--timeout-ms must be an integer from 1 to 3600000");
+ }
+ const context = await handlers.createTrainingContext(inputPath, {
+ agent: options.agent as string | undefined, packageVersion
+ });
+ const args: string[] = [];
+ for (const name of forwarded) {
+ const value = options[key(name)];
+ if (typeof value === "string") args.push(`--${name}`, value);
+ else if (Array.isArray(value)) for (const item of value) args.push(`--${name}`, item);
+ }
+ if (options.dryRun === true) args.push("--dry-run");
+ if (options.resume === true) args.push("--resume");
+ setExitCode(await handlers.delegatePaideiaTraining({ context, args,
+ trainingImage: options.trainingImage as string | undefined, trainingConfig: options.trainingConfig as string | undefined,
+ command: options.paideiaCommand as string, dryRun: options.dryRun === true,
+ timeoutMs: timeout + 5000, streams, signal }));
+ });
+};
diff --git a/src/compiler/AGENTS.md b/src/compiler/AGENTS.md
index 273b82b..b2a7bda 100644
--- a/src/compiler/AGENTS.md
+++ b/src/compiler/AGENTS.md
@@ -6,6 +6,7 @@ This folder owns graph resolution, effective configuration, compile planning, an
```text
src/compiler/
+├── training/ # Versioned canonical source context for Paideia; no evaluation or launch
├── index.ts # Barrel for compiler-facing exports
├── types.ts # Internal compiler plan and resolved-node types
├── helpers.ts # Deterministic helper utilities
diff --git a/src/compiler/index.ts b/src/compiler/index.ts
index ca46f96..4568f74 100644
--- a/src/compiler/index.ts
+++ b/src/compiler/index.ts
@@ -19,3 +19,4 @@ export * from "./updateProjectSurfaces.js";
export * from "./upReceipt.js";
export * from "./worldBindings.js";
export * from "./view/index.js";
+export * from "./training/index.js";
diff --git a/src/compiler/training/AGENTS.md b/src/compiler/training/AGENTS.md
new file mode 100644
index 0000000..825728f
--- /dev/null
+++ b/src/compiler/training/AGENTS.md
@@ -0,0 +1,11 @@
+# Canonical Training Context
+
+- `container/` owns the single-container actual-training launch; no model process runs on the host.
+- `contract.ts` owns the strict, versioned public JSON handoff to Paideia.
+- `context.ts` resolves the full compiler graph and pins source files without compiling or launching it.
+- Preserve resolved inheritance and exact agent IDs. Never create a second agent declaration.
+- Source mappings are project-relative editable files, not runtime-native destinations or flattened prompts.
+- Do not serialize secret values, arbitrary environments or transport credentials.
+- Paideia owns datasets, evaluation, budgets and optimization. Its native integration must consume Spawnfile compilation.
+- Dry-run extraction performs local reads only; no Docker, auth, deployment or model calls.
+- Keep files below 400 lines and tests adjacent. Test real graph resolution and negative source/selection cases.
diff --git a/src/compiler/training/CLAUDE.md b/src/compiler/training/CLAUDE.md
new file mode 120000
index 0000000..47dc3e3
--- /dev/null
+++ b/src/compiler/training/CLAUDE.md
@@ -0,0 +1 @@
+AGENTS.md
\ No newline at end of file
diff --git a/src/compiler/training/container/AGENTS.md b/src/compiler/training/container/AGENTS.md
new file mode 100644
index 0000000..d26458e
--- /dev/null
+++ b/src/compiler/training/container/AGENTS.md
@@ -0,0 +1,9 @@
+# Training Container Launcher
+
+- One immutable image runs the whole experiment; the host only prepares declared mounts and supervises Docker.
+- `contract.ts` validates explicit launch configuration. `prepare.ts` maps canonical context and CLI paths into declared mounts.
+- `process.ts` owns bounded Docker subprocess transport. `launch.ts` owns exact container identity, streaming, receipt verification and cleanup.
+- Never mount a Docker socket, host home, arbitrary environment, or host executable. Auth bindings are explicit read-only leaf files.
+- Dry-run stays in the existing host estimator. Actual training must never fall back to host execution.
+- Docker bind mounts currently require an explicitly selected local Unix-socket context. Remote daemon staging is unsupported.
+- Keep files under 400 lines; adjacent negative tests must prove ownership, cancellation and final receipt checks.
diff --git a/src/compiler/training/container/CLAUDE.md b/src/compiler/training/container/CLAUDE.md
new file mode 120000
index 0000000..47dc3e3
--- /dev/null
+++ b/src/compiler/training/container/CLAUDE.md
@@ -0,0 +1 @@
+AGENTS.md
\ No newline at end of file
diff --git a/src/compiler/training/container/contract.ts b/src/compiler/training/container/contract.ts
new file mode 100644
index 0000000..fdebbf6
--- /dev/null
+++ b/src/compiler/training/container/contract.ts
@@ -0,0 +1,20 @@
+import path from "node:path";
+import { z } from "zod";
+
+const hostPath = z.string().min(1).refine((value) => path.isAbsolute(value) && !/[,\r\n\0]/u.test(value), "Expected an absolute bind path");
+const inputPath = z.string().regex(/^\/run\/training\/inputs\/[A-Za-z0-9._/-]+$/u).refine((value) => path.posix.normalize(value) === value && !value.endsWith("/"));
+export const trainingImageSchema = z.string().regex(/^(?:sha256:[a-f0-9]{64}|[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[a-f0-9]{64})$/u);
+export const trainingContainerConfigSchema = z.object({
+ version: z.literal("spawnfile.training-container.v1"),
+ dockerContext: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/u),
+ inputs: z.array(z.object({ source: hostPath, destination: inputPath }).strict()).min(1).max(64),
+ output: z.object({ source: hostPath, destination: z.literal("/run/training/output") }).strict(),
+ auth: z.array(z.object({ source: hostPath, provider: z.enum(["codex", "grok", "claude"]) }).strict()).max(3)
+}).strict().superRefine((value, context) => {
+ const destinations = value.inputs.map((entry) => entry.destination);
+ if (destinations.some((entry, index) => destinations.some((other, otherIndex) => otherIndex !== index && (entry === other || entry.startsWith(`${other}/`))))) {
+ context.addIssue({ code: "custom", message: "Input destinations must not overlap" });
+ }
+ if (new Set(value.auth.map((entry) => entry.provider)).size !== value.auth.length) context.addIssue({ code: "custom", message: "Duplicate auth provider" });
+});
+export type TrainingContainerConfig = z.infer;
diff --git a/src/compiler/training/container/fixtures.test-helper.ts b/src/compiler/training/container/fixtures.test-helper.ts
new file mode 100644
index 0000000..6a99038
--- /dev/null
+++ b/src/compiler/training/container/fixtures.test-helper.ts
@@ -0,0 +1,48 @@
+import { mkdtemp, mkdir, realpath, writeFile } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import type { TrainingContext } from "../contract.js";
+import type { TrainingDockerProcess } from "./process.js";
+
+export const image = `sha256:${"a".repeat(64)}`;
+export const id = "b".repeat(64);
+export const fixture = async () => {
+ const root = await realpath(await mkdtemp(path.join(os.tmpdir(), "spawnfile-container-test-")));
+ const project = path.join(root, "project"), output = path.join(root, "output");
+ await mkdir(project); await mkdir(output);
+ await writeFile(path.join(project, "Spawnfile"), "fixture");
+ await writeFile(path.join(project, "train.yaml"), "fixture");
+ await writeFile(path.join(output, "index.json"), "{}");
+ await writeFile(path.join(root, "auth-leaf"), "fake-fixture-token");
+ const source = { sourcePath: path.join(project, "Spawnfile"), destinationPath: "Spawnfile", sha256: image };
+ const context: TrainingContext = { version: "spawnfile.training-context.v1", producer: { package: "spawnfile", version: "test" },
+ project: { root: project, manifest: source.sourcePath, sourceDigest: image },
+ agent: { id: "agent:a", name: "a", source: source.sourcePath, runtime: "daimon", engine: null, model: null },
+ sources: [source], documents: [{ ...source, role: "system" }], skills: [{ ...source, name: "skill", ref: "skill", requiresMcp: [] }], resources: [],
+ requirements: { nativeCompilation: true, isolatedPreparation: true } };
+ const config = { version: "spawnfile.training-container.v1", dockerContext: "desktop-linux", inputs: [{ source: project, destination: "/run/training/inputs/project" }],
+ output: { source: output, destination: "/run/training/output" }, auth: [{ source: path.join(root, "auth-leaf"), provider: "grok" }] };
+ const configPath = path.join(root, "launch.json"); await writeFile(configPath, JSON.stringify(config));
+ const args = ["--train", path.join(project, "train.yaml"), "--out", output];
+ return { root, project, output, context, config, configPath, args };
+};
+export const dockerFixture = (override?: (args: readonly string[], options: Parameters[1]) => Promise<{code:number;stdout:string;stderr:string} | undefined>) => {
+ const calls: string[][] = []; let name = "";
+ const process: TrainingDockerProcess = async (args, options) => {
+ calls.push([...args]);
+ const custom = await override?.(args, options); if (custom) return custom;
+ const result = (stdout: string, code = 0) => ({ stdout, code, stderr: "" });
+ if (args[0] === "context") return result(JSON.stringify("unix:///var/run/docker.sock"));
+ const command = args[2];
+ if (command === "image") return result(image);
+ if (command === "create") { name = args[args.indexOf("--name") + 1]!; return result(id); }
+ if (command === "inspect") {
+ if (args[4] === "{{json .State}}") return result(JSON.stringify({ Running: false, ExitCode: 0 }));
+ return result([id, `/${name}`, image, { "com.spawnfile.training.owner": name }].map((part) => JSON.stringify(part)).join("\n"));
+ }
+ if (command === "start") { options.stdout?.('measuring'); options.stdout?.('{"status":"completed","index":"/run/training/output/index.json"}'); return result(""); }
+ if (command === "rm" || command === "container") return result("");
+ throw Error(`Unexpected fake Docker command ${args}`);
+ };
+ return { process, calls };
+};
diff --git a/src/compiler/training/container/index.ts b/src/compiler/training/container/index.ts
new file mode 100644
index 0000000..3c50bf2
--- /dev/null
+++ b/src/compiler/training/container/index.ts
@@ -0,0 +1,4 @@
+export { trainingContainerConfigSchema, trainingImageSchema } from "./contract.js";
+export type { TrainingContainerConfig } from "./contract.js";
+export { launchTrainingContainer } from "./launch.js";
+export type { LaunchTrainingContainerOptions } from "./launch.js";
diff --git a/src/compiler/training/container/launch.test.ts b/src/compiler/training/container/launch.test.ts
new file mode 100644
index 0000000..13e5dde
--- /dev/null
+++ b/src/compiler/training/container/launch.test.ts
@@ -0,0 +1,156 @@
+import { readFile, rm, stat, writeFile } from "node:fs/promises";
+import path from "node:path";
+import os from "node:os";
+import { afterEach, expect, it, vi } from "vitest";
+import { fixture, dockerFixture, image, id } from "./fixtures.test-helper.js";
+import { launchTrainingContainer } from "./launch.js";
+const roots: string[] = [];
+afterEach(async () => { vi.restoreAllMocks(); for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }); });
+const setup = async () => { const value = await fixture(); roots.push(value.root); return value; };
+const streams = () => ({ stdout: vi.fn(), stderr: vi.fn() });
+it("launches one immutable container, streams a verified persisted completion and removes only its verified identity", async () => {
+ const f = await setup(); let observedContext: unknown;
+ const docker = dockerFixture(async (args) => {
+ if (args[2] === "create") {
+ const mount = args.find((entry) => entry.includes("dst=/run/paideia/context.json"))!;
+ observedContext = JSON.parse(await readFile(mount.split("src=")[1]!.split(",")[0]!, "utf8"));
+ }
+ return undefined;
+ }); const output = streams();
+ expect(await launchTrainingContainer({ ...f, image, timeoutMs: 1000, streams: output, process: docker.process })).toBe(0);
+ expect(observedContext).toMatchObject({ project: { root: "/run/training/inputs/project" } });
+ const create = docker.calls.find((args) => args[2] === "create")!;
+ expect(create).toContain("/opt/training/bin/train"); expect(create).toContain("HOME=/home/training");
+ expect(create).toContain("--user"); expect(create).toContain(`${process.getuid!()}:${process.getgid!()}`);
+ expect(create).toContain("--security-opt=seccomp=unconfined"); expect(create).toContain("--security-opt=apparmor=unconfined");
+ expect(create.some((value) => value.startsWith("/work:") && value.includes(`uid=${process.getuid!()}`))).toBe(true);
+ expect(create).toContain(image); expect(create).not.toContain("--privileged");
+ expect(create.join(" ")).not.toContain("docker.sock");
+ expect(create.join(" ")).toContain("dst=/run/paideia-auth/grok,readonly");
+ expect(output.stdout).toHaveBeenLastCalledWith('{"status":"completed","index":"/run/training/output/index.json"}');
+ expect(docker.calls.some((args) => args[2] === "rm" && args.at(-1) === id)).toBe(true);
+});
+it("rejects remote contexts and missing images before container creation", async () => {
+ const f = await setup();
+ for (const remote of [true, false]) {
+ const docker = dockerFixture(async (args) => args[0] === "context" && remote ? {code:0,stdout:'"ssh://host"',stderr:""} : args[2] === "image" ? {code:1,stdout:"",stderr:""}: undefined);
+ await expect(launchTrainingContainer({...f,image,timeoutMs:1000,streams:streams(),process:docker.process})).rejects.toThrow();
+ expect(docker.calls.some((args) => args[2] === "create")).toBe(false);
+ }
+});
+it("rejects malformed completion, missing artifact, running state and inconsistent exit evidence", async () => {
+ const f = await setup();
+ for (const text of ["not json", '{"status":"completed","index":"/etc/passwd"}', '{"status":"completed","index":"/run/training/output/missing.json"}', '{"status":"pending","index":"/run/training/output/index.json"}']) {
+ const docker = dockerFixture(async (args, options) => { if (args[2] === "start") {options.stdout?.(text);return {code:0,stdout:"",stderr:""};} return undefined; });
+ await expect(launchTrainingContainer({...f,image,timeoutMs:1000,streams:streams(),process:docker.process})).rejects.toThrow();
+ expect(docker.calls.some((args) => args[2] === "rm")).toBe(true);
+ }
+ for (const state of [{Running:true,ExitCode:0},{Running:false,ExitCode:1}]) {
+ const docker = dockerFixture(async (args) => args[4] === "{{json .State}}" ? {code:0,stdout:JSON.stringify(state),stderr:""}:undefined);
+ await expect(launchTrainingContainer({...f,image,timeoutMs:1000,streams:streams(),process:docker.process})).rejects.toThrow("final state");
+ }
+});
+it("refuses foreign ownership and reports unverified cleanup", async () => {
+ const f = await setup();
+ const foreign = dockerFixture(async (args) => args[2] === "inspect" ? { code:0,stdout:[id,"/foreign",image,{}].map((value) => JSON.stringify(value)).join("\n"),stderr:"" }:undefined);
+ await expect(launchTrainingContainer({...f,image,timeoutMs:1000,streams:streams(),process:foreign.process})).rejects.toThrow();
+ expect(foreign.calls.some((args) => args[2] === "rm")).toBe(false);
+ const failed = dockerFixture(async (args) => args[2] === "container" ? {code:0,stdout:id,stderr:""}:undefined);
+ await expect(launchTrainingContainer({...f,image,timeoutMs:1000,streams:streams(),process:failed.process})).rejects.toThrow("cleanup is unverified");
+});
+it("cancellation and deadlines stop the real owned container, including a lost create response", async () => {
+ const f = await setup();
+ for (const phase of ["create", "start"]) {
+ const controller = new AbortController();
+ const docker = dockerFixture(async (args) => { if (args[2] === phase) { controller.abort(); if (phase === "start") throw Error("cancelled"); } return undefined; });
+ expect(await launchTrainingContainer({...f,image,timeoutMs:1000,signal:controller.signal,streams:streams(),process:docker.process})).toBe(130);
+ expect(docker.calls.some((args) => args[2] === "rm")).toBe(true);
+ }
+ const docker = dockerFixture(async (args, options) => {
+ if (args[2] === "start") await new Promise((_resolve,reject) => options.signal!.addEventListener("abort",()=>reject(Error("deadline")),{once:true}));
+ return undefined;
+ });
+ await expect(launchTrainingContainer({...f,image,timeoutMs:50,streams:streams(),process:docker.process})).rejects.toThrow("deadline");
+ expect(docker.calls.some((args) => args[2] === "rm")).toBe(true);
+ const already = new AbortController();already.abort();
+ expect(await launchTrainingContainer({...f,image,timeoutMs:1000,signal:already.signal,streams:streams(),process:docker.process})).toBe(130);
+});
+it("refuses oversized launch configuration", async () => {
+ const f = await setup();await writeFile(f.configPath," ".repeat(1024*1024+1));
+ await expect(launchTrainingContainer({...f,image,timeoutMs:1000,streams:streams()})).rejects.toThrow("1 MiB");
+});
+it("preserves non-success exit codes and proves absence when create leaves no container", async () => {
+ const f = await setup();
+ const exited = dockerFixture(async (args) => args[2] === "start" ? {code:2,stdout:"",stderr:""} : args[4] === "{{json .State}}" ? {code:0,stdout:'{"Running":false,"ExitCode":2}',stderr:""}:undefined);
+ expect(await launchTrainingContainer({...f,image,timeoutMs:1000,streams:streams(),process:exited.process})).toBe(2);
+ for(const remains of ["",id]) {
+ const missing = dockerFixture(async (args)=> args[2] === "create" ? {code:1,stdout:"",stderr:""} : args[2] === "inspect" ? {code:1,stdout:"",stderr:""}: args[2] === "container" ? {code:0,stdout:remains,stderr:""}:undefined);
+ await expect(launchTrainingContainer({...f,image,timeoutMs:1000,streams:streams(),process:missing.process})).rejects.toThrow(remains ? "closure is unknown" : "valid training container");
+ }
+});
+
+it("refuses a root caller before any Docker invocation", async () => {
+ const f=await setup(),docker=dockerFixture();const uid=vi.spyOn(process as {getuid:()=>number},"getuid").mockReturnValue(0);
+ try { await expect(launchTrainingContainer({...f,image,timeoutMs:1000,streams:streams(),process:docker.process})).rejects.toThrow("non-root");expect(docker.calls).toEqual([]); } finally {uid.mockRestore();}
+});
+
+it("observes cancellation that arrives while preparing filesystem inputs", async () => {
+ const f=await setup(),docker=dockerFixture(),controller=new AbortController();
+ const pending=launchTrainingContainer({...f,image,timeoutMs:1000,signal:controller.signal,streams:streams(),process:docker.process});
+ queueMicrotask(()=>controller.abort());
+ expect(await pending).toBe(130);expect(docker.calls.every((args)=>args[2]!=="create")).toBe(true);
+});
+it("cleans a verified container even when create returned a truncated ID",async()=>{
+ const f=await setup();let name="";
+ const docker=dockerFixture(async(args)=>{
+ if(args[2]==="create"){name=args[args.indexOf("--name")+1]!;return {code:0,stdout:id.slice(0,12),stderr:""};}
+ if(args[2]==="inspect")return {code:0,stdout:[id,`/${name}`,image,{"com.spawnfile.training.owner":name}].map((v)=>JSON.stringify(v)).join("\n"),stderr:""};
+ return undefined;
+ });
+ await expect(launchTrainingContainer({...f,image,timeoutMs:1000,streams:streams(),process:docker.process})).rejects.toThrow("valid training container identity");
+ expect(docker.calls.some((args)=>args[2]==="rm"&&args.at(-1)===id)).toBe(true);
+});
+
+it("publishes the live cockpit only on host loopback and removes its container on cancellation",async()=>{
+ const f=await setup(),controller=new AbortController();
+ const docker=dockerFixture(async(args)=>{if(args[2]==="start"){controller.abort();throw Error("cancelled");}return undefined;});
+ expect(await launchTrainingContainer({...f,args:[...f.args,"--view","53484"],image,timeoutMs:1000,signal:controller.signal,streams:streams(),process:docker.process})).toBe(130);
+ const create=docker.calls.find((args)=>args[2]==="create")!;
+ expect(create[create.indexOf("--publish")+1]).toBe("127.0.0.1:53484:53484");
+ expect(create).not.toContain("0.0.0.0:53484:53484");expect(docker.calls.some((args)=>args[2]==="rm")).toBe(true);
+});
+
+
+it("stages private readonly context beside the shared output, never in host temporary storage", async () => {
+ const f = await setup(); let scratch = "";
+ const tmp = vi.spyOn(os, "tmpdir").mockImplementation(() => { throw Error("Host temp cannot be staged for Docker"); });
+ const docker = dockerFixture(async (args) => {
+ if (args[2] === "create") {
+ const mount = args.find((entry) => entry.includes("dst=/run/paideia/context.json"))!;
+ expect(mount.endsWith(",readonly")).toBe(true);
+ const file = mount.split("src=")[1]!.split(",")[0]!; scratch = path.dirname(file);
+ expect(path.dirname(scratch)).toBe(path.dirname(f.output));
+ expect(scratch.startsWith(f.output + path.sep)).toBe(false);
+ expect(f.config.inputs.some((entry) => scratch === entry.source || scratch.startsWith(entry.source + path.sep))).toBe(false);
+ expect((await stat(scratch)).mode & 0o777).toBe(0o700);
+ expect((await stat(file)).mode & 0o777).toBe(0o444);
+ }
+ return undefined;
+ });
+ expect(await launchTrainingContainer({ ...f, image, timeoutMs: 1000, streams: streams(), process: docker.process })).toBe(0);
+ expect(tmp).not.toHaveBeenCalled();
+ await expect(stat(scratch)).rejects.toMatchObject({ code: "ENOENT" });
+});
+
+it("retains bounded useful Docker create errors while redacting declared auth paths", async () => {
+ const f = await setup();
+ const docker = dockerFixture(async (args) => args[2] === "create" ? { code: 1, stdout: "",
+ stderr: `invalid mount config: bind source path does not exist: /shared/context.json\ncredential=${f.config.auth[0]!.source} ${"x".repeat(4000)}` }
+ : args[2] === "inspect" ? { code: 1, stdout: "", stderr: "No container" } : undefined);
+ const error = await launchTrainingContainer({ ...f, image, timeoutMs: 1000, streams: streams(), process: docker.process }).catch((failure: unknown) => failure);
+ expect(error).toBeInstanceOf(Error);
+ const message = (error as Error).message;
+ expect(message).toContain("bind source path does not exist: /shared/context.json");
+ expect(message).toContain("[auth source]"); expect(message).not.toContain(f.config.auth[0]!.source);
+ expect(message).not.toContain("\n"); expect(message.length).toBeLessThan(2200);
+});
diff --git a/src/compiler/training/container/launch.ts b/src/compiler/training/container/launch.ts
new file mode 100644
index 0000000..fb97938
--- /dev/null
+++ b/src/compiler/training/container/launch.ts
@@ -0,0 +1,125 @@
+import { randomUUID } from "node:crypto";
+import { chmod, mkdtemp, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
+import path from "node:path";
+import { DAIMON_CODEX_NATIVE_SANDBOX_DOCKER_SECURITY_OPTS } from "../../../shared/index.js";
+import { parseDetachedContainerInspect } from "../../runProjectDocker.js";
+import type { TrainingContext } from "../contract.js";
+import { trainingImageSchema } from "./contract.js";
+import { prepareTrainingContainer } from "./prepare.js";
+import { runTrainingDocker, type TrainingDockerProcess } from "./process.js";
+import { parseTrainingMappedPreparation } from "../preparation/contract.js";
+
+export interface LaunchTrainingContainerOptions {
+ image: string; configPath: string; context: TrainingContext; args: readonly string[];
+ timeoutMs: number; signal?: AbortSignal;
+ streams: { stdout(line: string): void; stderr(line: string): void };
+ process?: TrainingDockerProcess;
+ preparationPath?: string;
+}
+const inspectFormat = "{{json .Id}}\n{{json .Name}}\n{{json .Image}}\n{{json .Config.Labels}}";
+export const launchTrainingContainer = async (options: LaunchTrainingContainerOptions): Promise => {
+ if (options.signal?.aborted) return 130;
+ const image = trainingImageSchema.parse(options.image);
+ const uid = process.getuid?.(), gid = process.getgid?.();
+ if (uid === undefined || gid === undefined || uid === 0) throw Error("Training requires an explicit non-root local user");
+ const configBytes = await readFile(options.configPath, "utf8");
+ if (Buffer.byteLength(configBytes) > 1024 * 1024) throw Error("Training launch config exceeds 1 MiB");
+ const prepared = await prepareTrainingContainer(JSON.parse(configBytes), options.context, options.args);
+ if (options.preparationPath) {
+ if (await realpath(options.preparationPath) !== options.preparationPath) throw Error("Preparation receipt path must be canonical");
+ parseTrainingMappedPreparation(JSON.parse(await readFile(options.preparationPath, "utf8")));
+ }
+ const execute = options.process ?? runTrainingDocker;
+ const controller = new AbortController(), deadline = Date.now() + options.timeoutMs;
+ let interrupted = false, timedOut = false;
+ const interrupt = (): void => { interrupted = true; controller.abort(); };
+ const timer = setTimeout(() => { timedOut = true; controller.abort(); }, options.timeoutMs);
+ options.signal?.addEventListener("abort", interrupt, { once: true });
+ process.once("SIGINT", interrupt); process.once("SIGTERM", interrupt);
+ if (options.signal?.aborted) interrupt();
+ const prefix = ["--context", prepared.config.dockerContext];
+ const call = async (args: string[], cleanup = false, stream = false) => {
+ if (!cleanup && controller.signal.aborted) throw Error("Container operation cancelled");
+ return execute([...prefix, ...args], {
+ timeoutMs: cleanup ? 10_000 : Math.max(1, deadline - Date.now()),
+ ...(cleanup ? {} : { signal: controller.signal }), ...(stream ? options.streams : {})
+ }); };
+ const name = `spawnfile-training-${randomUUID()}`;
+ const labels = { "com.spawnfile.training.owner": name };
+ let privateRoot: string | undefined, containerId: string | undefined, creationAttempted = false;
+ let expectedImage: string | undefined, lastLine = "", closureVerified = false;
+ try {
+ const endpoint = await execute(["context", "inspect", prepared.config.dockerContext, "--format", "{{json .Endpoints.docker.Host}}"], { timeoutMs: 10_000, signal: controller.signal });
+ if (endpoint.code !== 0 || !/^unix:\/\//u.test(JSON.parse(endpoint.stdout))) throw Error("Training requires an explicitly selected local Unix Docker context");
+ const inspected = await call(["image", "inspect", image, "--format", "{{.Id}}"]);
+ expectedImage = inspected.stdout.trim();
+ if (inspected.code !== 0 || !/^sha256:[a-f0-9]{64}$/u.test(expectedImage) || (image.startsWith("sha256:") && image !== expectedImage)) throw Error("Training image must be locally available with an immutable identity");
+ privateRoot = await realpath(await mkdtemp(path.join(path.dirname(prepared.config.output.source), ".spawnfile-training-container-")));
+ await chmod(privateRoot, 0o700);
+ const contextFile = path.join(privateRoot, "context.json");
+ await writeFile(contextFile, JSON.stringify(prepared.context), { mode: 0o444, flag: "wx" });
+ const mounts = [...prepared.config.inputs.map((entry) => `type=bind,src=${entry.source},dst=${entry.destination},readonly`),
+ `type=bind,src=${prepared.config.output.source},dst=/run/training/output`,
+ `type=bind,src=${contextFile},dst=/run/paideia/context.json,readonly`,
+ ...options.preparationPath ? [`type=bind,src=${options.preparationPath},dst=/run/paideia/preparation.json,readonly`] : [],
+ ...prepared.config.auth.map((entry) => `type=bind,src=${entry.source},dst=/run/paideia-auth/${entry.provider},readonly`)];
+ const args = ["create", "--name", name, "--label", `com.spawnfile.training.owner=${name}`,
+ "--init", "--read-only", "--user", `${uid}:${gid}`, "--cap-drop", "ALL", "--security-opt", "no-new-privileges",
+ ...DAIMON_CODEX_NATIVE_SANDBOX_DOCKER_SECURITY_OPTS, "--pids-limit", "512",
+ "--tmpfs", `/tmp:rw,nosuid,nodev,size=1g,uid=${uid},gid=${gid},mode=1777`, "--tmpfs", `/work:rw,nosuid,nodev,size=4g,uid=${uid},gid=${gid},mode=700`, "--tmpfs", `/home/training:rw,nosuid,nodev,size=1g,uid=${uid},gid=${gid},mode=700`,
+ "--env", "HOME=/home/training", "--workdir", "/work", "--entrypoint", "/opt/training/bin/train",
+ ...mounts.flatMap((mount) => ["--mount", mount]),
+ ...(prepared.viewerPort === undefined ? [] : ["--publish", `127.0.0.1:${prepared.viewerPort}:${prepared.viewerPort}`]), expectedImage,
+ "train", "--spawnfile-context", "/run/paideia/context.json", ...prepared.args];
+ creationAttempted = true;
+ const created = await call(args);
+ containerId = created.stdout.trim();
+ if (created.code !== 0 || !/^[a-f0-9]{64}$/u.test(containerId)) {
+ let diagnostic = created.stderr;
+ for (const auth of prepared.config.auth) diagnostic = diagnostic.replaceAll(auth.source, "[auth source]");
+ diagnostic = diagnostic.replace(/\p{Cc}/gu, " ").trim().slice(0, 2048);
+ throw Error(`Docker did not return a valid training container identity${diagnostic ? `: ${diagnostic}` : ""}`);
+ }
+ const identity = await call(["inspect", "--format", inspectFormat, containerId]);
+ if (identity.code !== 0 || parseDetachedContainerInspect(identity.stdout, containerId, labels, name).imageId !== expectedImage) throw Error("Training container identity mismatch");
+ if (controller.signal.aborted) throw Error("Container operation cancelled");
+ const result = await execute([...prefix, "start", "--attach", containerId], {
+ timeoutMs: Math.max(1, deadline - Date.now()), signal: controller.signal,
+ stdout: (line) => { if (line.trim()) lastLine = line; options.streams.stdout(line); }, stderr: options.streams.stderr
+ });
+ const terminal = await call(["inspect", "--format", "{{json .State}}", containerId]);
+ const state = JSON.parse(terminal.stdout) as { Running?: unknown; ExitCode?: unknown };
+ if (terminal.code !== 0 || state.Running !== false || !Number.isInteger(state.ExitCode) || result.code !== state.ExitCode) throw Error("Training container final state is unverified");
+ if (state.ExitCode !== 0 && state.ExitCode !== 1) return state.ExitCode as number;
+ const receipt = JSON.parse(lastLine) as { status?: unknown; index?: unknown };
+ if (receipt.status !== "completed" || typeof receipt.index !== "string" || !receipt.index.startsWith("/run/training/output/") || path.posix.normalize(receipt.index) !== receipt.index) throw Error("Training exited without a valid final receipt");
+ const artifact = path.join(prepared.config.output.source, receipt.index.slice("/run/training/output/".length));
+ if (await realpath(artifact) !== artifact || !(await stat(artifact)).isFile()) throw Error("Training completion artifact is missing or escapes output");
+ return state.ExitCode as number;
+ } catch (error) {
+ if (interrupted) return 130;
+ if (timedOut) throw Error("Container training exceeded command deadline");
+ throw error;
+ } finally {
+ clearTimeout(timer); options.signal?.removeEventListener("abort", interrupt);
+ process.removeListener("SIGINT", interrupt); process.removeListener("SIGTERM", interrupt);
+ try {
+ if (creationAttempted) {
+ // A timed-out create may still have created our uniquely labelled container.
+ const found = await call(["inspect", "--format", inspectFormat, name], true);
+ if (found.code === 0) {
+ const id: unknown = JSON.parse(found.stdout.split("\n")[0]!);
+ if (typeof id !== "string" || parseDetachedContainerInspect(found.stdout, id, labels, name).imageId !== expectedImage || (containerId && /^[a-f0-9]{64}$/u.test(containerId) && id !== containerId)) throw Error("Refusing cleanup of unverified training container");
+ const removed = await call(["rm", "--force", id], true);
+ const remaining = await call(["container", "ls", "--all", "--no-trunc", "--filter", `id=${id}`, "--format", "{{.ID}}"], true);
+ if (removed.code !== 0 || remaining.code !== 0 || remaining.stdout.trim()) throw Error("Training container cleanup is unverified");
+ closureVerified = true;
+ } else {
+ const remaining = await call(["container", "ls", "--all", "--filter", `name=^/${name}$`, "--format", "{{.ID}}"], true);
+ if (remaining.code !== 0 || remaining.stdout.trim()) throw Error("Training container closure is unknown");
+ closureVerified = true;
+ }
+ }
+ } finally { if (privateRoot && (!creationAttempted || closureVerified)) await rm(privateRoot, { recursive: true, force: true }); }
+ }
+};
diff --git a/src/compiler/training/container/prepare.test.ts b/src/compiler/training/container/prepare.test.ts
new file mode 100644
index 0000000..78adac7
--- /dev/null
+++ b/src/compiler/training/container/prepare.test.ts
@@ -0,0 +1,86 @@
+import { mkdir, rename, rm, symlink, writeFile } from "node:fs/promises";
+import path from "node:path";
+import os from "node:os";
+import { afterEach, expect, it, vi } from "vitest";
+import { fixture } from "./fixtures.test-helper.js";
+import { prepareTrainingContainer } from "./prepare.js";
+import { trainingContainerConfigSchema, trainingImageSchema } from "./contract.js";
+const roots: string[] = [];
+afterEach(async () => { vi.restoreAllMocks(); for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }); });
+const setup = async () => { const value = await fixture(); roots.push(value.root); return value; };
+it("maps canonical sources and CLI paths, leaving installed bridge and project-relative editables intact", async () => {
+ const f = await setup();
+ const result = await prepareTrainingContainer(f.config, f.context, [...f.args, "--resource", `archive=${f.project}`, "--bridge-command", "/opt/training/bin/bridge", "--editable", "a.md"]);
+ expect(result.context.project.root).toBe("/run/training/inputs/project");
+ expect(result.context.sources[0]?.sourcePath).toBe("/run/training/inputs/project/Spawnfile");
+ expect(result.context.documents[0]?.sourcePath).toBe("/run/training/inputs/project/Spawnfile");
+ expect(result.context.skills[0]?.sourcePath).toBe("/run/training/inputs/project/Spawnfile");
+ expect(result.args).toEqual(["--train", "/run/training/inputs/project/train.yaml", "--out", "/run/training/output", "--resource", "archive=/run/training/inputs/project", "--bridge-command", "/opt/training/bin/bridge", "--editable", "a.md"]);
+});
+it("rejects mutable images, extra authority and overlapping destinations", async () => {
+ const f = await setup();
+ for (const image of ["latest", "image:tag", "sha256:bad", "a@sha256:"+"a".repeat(63)]) expect(trainingImageSchema.safeParse(image).success).toBe(false);
+ for (const value of [{ ...f.config, command: "shell" }, { ...f.config, auth: [f.config.auth[0], { ...f.config.auth[0] }] },
+ { ...f.config, inputs: [...f.config.inputs, { source: f.output, destination: "/run/training/inputs/project/sub" }] },
+ { ...f.config, inputs: [{ source: f.project, destination: "/run/training/inputs/../run" }] }]) expect(trainingContainerConfigSchema.safeParse(value).success).toBe(false);
+});
+it("rejects unmapped paths, output under readonly mounts, malformed resources and host executables/viewers", async () => {
+ const f = await setup();
+ for (const args of [["--train", "/missing"], ["--out", f.project], ["--train"], ["--resource", "bad"], ["--resource"], ["--bridge-command", "/usr/bin/bridge"], ["--bridge-command"], ["--view", "0"]]) {
+ await expect(prepareTrainingContainer(f.config, f.context, args)).rejects.toThrow();
+ }
+});
+it("rejects symlink aliases, whole auth directories and overlapping writable mounts", async () => {
+ const f = await setup(); const alias = path.join(f.root, "alias"); await symlink(f.project, alias);
+ for (const config of [{ ...f.config, inputs: [{ source: alias, destination: "/run/training/inputs/project" }] },
+ { ...f.config, auth: [{ provider: "grok", source: f.project }] },
+ { ...f.config, output: { source: f.project, destination: "/run/training/output" } },
+ { ...f.config, output: { source: path.join(f.root, "auth-leaf"), destination: "/run/training/output" } },
+ { ...f.config, inputs: [{ source: f.root, destination: "/run/training/inputs/project" }] }]) await expect(prepareTrainingContainer(config, f.context, f.args)).rejects.toThrow();
+ vi.spyOn(os, "homedir").mockReturnValue(f.root);
+ const cliHome = path.join(f.root, ".grok"); await mkdir(cliHome); await writeFile(path.join(cliHome,"auth.json"), "fixture");
+ await expect(prepareTrainingContainer({ ...f.config, inputs: [{ source: cliHome, destination: "/run/training/inputs/project" }] }, f.context, f.args)).rejects.toThrow("Host homes");
+});
+
+it("rejects noncanonical raw spellings and overlapping host input roots before remapping",async()=>{
+ const f=await setup();const inner=path.join(f.project,"inner");await mkdir(inner);const other=path.join(f.root,"other");await mkdir(other);const leaf=path.join(f.project,"credential");await writeFile(leaf,"fixture");
+ await expect(prepareTrainingContainer({...f.config,auth:[{provider:"grok",source:other+"/../project/credential"}]},f.context,f.args)).rejects.toThrow("canonical");
+ await expect(prepareTrainingContainer({...f.config,inputs:[...f.config.inputs,{source:inner,destination:"/run/training/inputs/inner"}]},f.context,f.args)).rejects.toThrow("source roots must not overlap");
+ await expect(prepareTrainingContainer({...f.config,output:{source:f.output+"/../output",destination:"/run/training/output"}},f.context,f.args)).rejects.toThrow("canonical");
+});
+
+it("accepts one explicit viewer port and rejects ephemeral, invalid or duplicate publication",async()=>{
+ const f=await setup();const prepared=await prepareTrainingContainer(f.config,f.context,[...f.args,"--view","53484"]);
+ expect(prepared.viewerPort).toBe(53484);expect(prepared.args.slice(-2)).toEqual(["--view","53484"]);
+ for(const values of [["0"],["65536"],["-1"],["1.5"],["127.0.0.1:3"],[""]])await expect(prepareTrainingContainer(f.config,f.context,[...f.args,"--view",...values])).rejects.toThrow("explicit port");
+ await expect(prepareTrainingContainer(f.config,f.context,[...f.args,"--view","1234","--view","1234"])).rejects.toThrow("explicit port");
+});
+
+
+it("accepts real nested project worktrees without treating their .claude directory as the host profile", async () => {
+ const f = await setup();
+ vi.spyOn(os, "homedir").mockReturnValue(f.root);
+ const worktree = path.join(f.root, "Documents", "project", ".claude", "worktrees", "training");
+ await mkdir(path.dirname(worktree), { recursive: true });
+ await rename(f.project, worktree);
+ const remap = (value: T): T => JSON.parse(JSON.stringify(value).replaceAll(f.project, worktree)) as T;
+ const result = await prepareTrainingContainer(remap(f.config), remap(f.context), remap(f.args));
+ expect(result.context.project.root).toBe("/run/training/inputs/project");
+ expect(result.config.inputs[0]?.source).toBe(worktree);
+ expect(result.args[1]).toBe("/run/training/inputs/project/train.yaml");
+});
+
+it("still rejects exact host roots, global profile subtrees and explicit auth exposure", async () => {
+ const f = await setup();
+ const home = path.join(f.root, "home"); await mkdir(home);
+ vi.spyOn(os, "homedir").mockReturnValue(home);
+ const denied = [home, "/"];
+ for (const name of [".codex", ".claude", ".grok", ".ssh", ".config"]) {
+ const root = path.join(home, name), nested = path.join(root, "nested");
+ await mkdir(nested, { recursive: true }); denied.push(root, nested);
+ }
+ for (const source of denied) {
+ await expect(prepareTrainingContainer({ ...f.config, inputs: [{ ...f.config.inputs[0], source }] }, f.context, f.args)).rejects.toThrow("Host homes");
+ }
+ await expect(prepareTrainingContainer({ ...f.config, inputs: [{ ...f.config.inputs[0], source: f.config.auth[0]!.source }] }, f.context, f.args)).rejects.toThrow("Auth must not be exposed");
+});
diff --git a/src/compiler/training/container/prepare.ts b/src/compiler/training/container/prepare.ts
new file mode 100644
index 0000000..fd04204
--- /dev/null
+++ b/src/compiler/training/container/prepare.ts
@@ -0,0 +1,66 @@
+import { lstat, realpath } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { trainingContextSchema, type TrainingContext } from "../contract.js";
+import { trainingContainerConfigSchema, type TrainingContainerConfig } from "./contract.js";
+
+const inside = (root: string, value: string): boolean => value === root || value.startsWith(`${root}/`);
+export const prepareTrainingContainer = async (raw: unknown, context: TrainingContext, args: readonly string[]): Promise<{
+ config: TrainingContainerConfig; context: TrainingContext; args: string[]; viewerPort?: number;
+}> => {
+ const config = trainingContainerConfigSchema.parse(raw);
+ const entries = [...config.inputs, config.output];
+ const home = path.resolve(os.homedir());
+ const globalConfigRoots = [".codex", ".claude", ".grok", ".ssh", ".config"].map((name) => path.join(home, name));
+ for (const entry of [...entries, ...config.auth]) {
+ const canonical = await realpath(entry.source);
+ if (canonical !== entry.source) throw Error("Training bind sources must be canonical, without symlink aliases");
+ const stat = await lstat(canonical);
+ if (stat.isSymbolicLink() || (!stat.isFile() && !stat.isDirectory())) throw Error("Training binds require regular files or directories");
+ if (config.auth.includes(entry as TrainingContainerConfig["auth"][number]) && !stat.isFile()) throw Error("Auth must be a single regular leaf file");
+ if (entries.includes(entry as TrainingContainerConfig["output"])) {
+ if (["/", "/etc", "/var", "/run", "/tmp", "/opt", "/usr", "/Users", "/home", home].includes(canonical) || globalConfigRoots.some((root) => inside(root, canonical))) throw Error("Host homes, configuration and system roots cannot be training inputs");
+ if (config.auth.some((auth) => inside(canonical, auth.source))) throw Error("Auth must not be exposed through input or output mounts");
+ }
+ }
+ if (config.inputs.some((entry, index) => config.inputs.some((other, otherIndex) => index !== otherIndex && inside(entry.source, other.source)))) throw Error("Training input source roots must not overlap");
+ if (!(await lstat(config.output.source)).isDirectory()) throw Error("Training output must be an existing directory");
+ if (config.inputs.some((entry) => inside(entry.source, config.output.source) || inside(config.output.source, entry.source))) throw Error("Training output and inputs must not overlap");
+ const map = (value: string): string => {
+ const absolute = path.resolve(value);
+ const entry = entries.find((item) => inside(item.source, absolute));
+ if (!entry) throw Error(`Training path is not covered by a declared mount: ${value}`);
+ return path.posix.join(entry.destination, path.relative(entry.source, absolute).split(path.sep).join("/"));
+ };
+ const source = (entry: T): T => ({ ...entry, sourcePath: map(entry.sourcePath) });
+ const mapped = trainingContextSchema.parse({ ...context,
+ project: { ...context.project, root: map(context.project.root), manifest: map(context.project.manifest) },
+ agent: { ...context.agent, source: map(context.agent.source) },
+ sources: context.sources.map(source), documents: context.documents.map(source), skills: context.skills.map(source)
+ });
+ const mappedArgs = [...args];
+ let viewerPort: number | undefined;
+ for (let index = 0; index < mappedArgs.length; index++) {
+ const flag = mappedArgs[index];
+ if (["--train", "--test", "--out", "--cost-config"].includes(flag!)) {
+ const value = mappedArgs[++index];
+ if (!value) throw Error(`Missing ${flag} path`);
+ mappedArgs[index] = map(value);
+ if (flag === "--out" && !inside("/run/training/output", mappedArgs[index]!)) throw Error("Training --out must use the writable output mount");
+ } else if (flag === "--resource") {
+ const value = mappedArgs[++index] ?? "", equals = value.indexOf("=");
+ if (equals < 1) throw Error("Expected resource=id path mapping");
+ mappedArgs[index] = `${value.slice(0, equals)}=${map(value.slice(equals + 1))}`;
+ } else if (flag === "--bridge-command") {
+ const value = mappedArgs[++index] ?? "";
+ if (!/^\/opt\/training\/[A-Za-z0-9._/-]+$/u.test(value) || path.posix.normalize(value) !== value) throw Error("Bridge must be an installed executable under /opt/training");
+ } else if (flag === "--view") {
+ const value = mappedArgs[++index] ?? "";
+ const port = Number(value);
+ if (viewerPort !== undefined || !/^\d+$/u.test(value) || !Number.isSafeInteger(port) || port < 1 || port > 65535) throw Error("Container --view requires one explicit port from 1 to 65535");
+ viewerPort = port;
+ mappedArgs[index] = String(port);
+ }
+ }
+ return { config, context: mapped, args: mappedArgs, ...(viewerPort === undefined ? {} : { viewerPort }) };
+};
diff --git a/src/compiler/training/container/process.test.ts b/src/compiler/training/container/process.test.ts
new file mode 100644
index 0000000..17f5feb
--- /dev/null
+++ b/src/compiler/training/container/process.test.ts
@@ -0,0 +1,40 @@
+import { EventEmitter } from "node:events";
+import { PassThrough } from "node:stream";
+import { afterEach, expect, it, vi } from "vitest";
+const spawn = vi.hoisted(() => vi.fn());
+vi.mock("node:child_process", () => ({ spawn }));
+import { runTrainingDocker } from "./process.js";
+afterEach(() => { vi.clearAllMocks(); });
+const child = () => {
+ const value = Object.assign(new EventEmitter(), { stdout: new PassThrough(), stderr: new PassThrough(), kill: vi.fn() });
+ value.kill.mockImplementation(() => { queueMicrotask(() => value.emit("close", null)); return true; });
+ spawn.mockReturnValue(value); return value;
+};
+it("runs only Docker without a shell, streams complete lines, and retains bounded tail", async () => {
+ const native = child(), output = vi.fn(), errors = vi.fn();
+ const result = runTrainingDocker(["start","--attach","owned"],{timeoutMs:1000,stdout:output,stderr:errors});
+ native.stdout.write("one\ntw");native.stdout.write("o\nfinal");native.stderr.write("warning\nlast");native.emit("close",0);
+ expect(await result).toEqual({code:0,stdout:"final",stderr:"last"});
+ expect(output.mock.calls.flat()).toEqual(["one","two","final"]);expect(errors.mock.calls.flat()).toEqual(["warning","last"]);
+ expect(spawn).toHaveBeenCalledWith("docker",["start","--attach","owned"],{shell:false,stdio:["ignore","pipe","pipe"]});
+});
+it("captures short inspection output and preserves exit code",async()=>{
+ const native=child();const pending=runTrainingDocker(["inspect"],{timeoutMs:1000});native.stdout.write("first\nlast");native.stderr.write("error\ntail");native.emit("close",2);expect(await pending).toEqual({code:2,stdout:"first\nlast",stderr:"error\ntail"});
+});
+it("cancels pending operations and refuses already cancelled launches",async()=>{
+ const native=child(),controller=new AbortController();const pending=runTrainingDocker(["start"],{timeoutMs:1000,signal:controller.signal});controller.abort();await expect(pending).rejects.toThrow("cancelled");expect(native.kill).toHaveBeenCalledWith("SIGKILL");
+ spawn.mockClear();await expect(runTrainingDocker([],{timeoutMs:1000,signal:controller.signal})).rejects.toThrow("cancelled");expect(spawn).not.toHaveBeenCalled();
+});
+it("bounds hangs and oversized line/cumulative output and surfaces launch errors",async()=>{
+ let native=child();let pending=runTrainingDocker([],{timeoutMs:10});await expect(pending).rejects.toThrow("deadline");
+ native=child();pending=runTrainingDocker([],{timeoutMs:1000});native.stdout.write("x".repeat(1024*1024+1));await expect(pending).rejects.toThrow("line size");
+ native=child();pending=runTrainingDocker([],{timeoutMs:1000});for(let i=0;i<4;i++)native.stdout.write("x".repeat(800000)+"\n");await expect(pending).rejects.toThrow("capture size");
+ native=child();pending=runTrainingDocker([],{timeoutMs:1000});native.emit("error",Error("missing docker"));native.emit("close",null);await expect(pending).rejects.toThrow("missing docker");
+});
+
+it("releases a stuck Docker client after kill so owned-container cleanup can proceed",async()=>{
+ const native=child();native.kill.mockImplementation(()=>true);
+ const controller=new AbortController(),pending=runTrainingDocker([],{timeoutMs:5000,signal:controller.signal});
+ controller.abort();await expect(pending).rejects.toThrow("cancelled");expect(native.stdout.destroyed).toBe(true);
+ native.emit("close",0);
+});
diff --git a/src/compiler/training/container/process.ts b/src/compiler/training/container/process.ts
new file mode 100644
index 0000000..20daa7e
--- /dev/null
+++ b/src/compiler/training/container/process.ts
@@ -0,0 +1,50 @@
+import { spawn } from "node:child_process";
+
+export interface TrainingDockerProcess {
+ (args: readonly string[], options: { timeoutMs: number; signal?: AbortSignal; stdout?: (line: string) => void; stderr?: (line: string) => void }): Promise<{ code: number; stdout: string; stderr: string }>;
+}
+/** Docker client only; no model executable runs on the host. */
+export const runTrainingDocker: TrainingDockerProcess = (args, options) => new Promise((resolve, reject) => {
+ if (options.signal?.aborted) { reject(Error("Training Docker operation cancelled")); return; }
+ const child = spawn("docker", [...args], { shell: false, stdio: ["ignore", "pipe", "pipe"] });
+ let stdout = "", stderr = "", failure: Error | undefined;
+ let forceClose: ReturnType | undefined;
+ let settled = false;
+ const pending = { stdout: "", stderr: "" };
+ const stop = (message: string): void => {
+ failure ??= Error(message); child.kill("SIGKILL");
+ forceClose ??= setTimeout(() => {
+ // Releasing a stuck client lets the owner independently stop/verify the container.
+ child.stdout.destroy(); child.stderr.destroy(); finish(null);
+ }, 1000);
+ };
+ const abort = (): void => stop("Training Docker operation cancelled");
+ const timer = setTimeout(() => stop("Training Docker operation exceeded deadline"), options.timeoutMs);
+ options.signal?.addEventListener("abort", abort, { once: true });
+ if (options.signal?.aborted) abort();
+ const consume = (channel: "stdout" | "stderr", chunk: string): void => {
+ pending[channel] += chunk;
+ if (Buffer.byteLength(pending[channel]) > 1024 * 1024) { stop("Docker output exceeded bounded line size"); return; }
+ const lines = pending[channel].split("\n"); pending[channel] = lines.pop()!;
+ for (const line of lines) {
+ options[channel]?.(line);
+ if (channel === "stdout") stdout = options.stdout ? line : stdout + line + "\n";
+ else stderr = options.stderr ? line : stderr + line + "\n";
+ if (stdout.length + stderr.length > 2 * 1024 * 1024) stop("Docker output exceeded bounded capture size");
+ }
+ };
+ child.stdout.setEncoding("utf8").on("data", (chunk: string) => consume("stdout", chunk));
+ child.stderr.setEncoding("utf8").on("data", (chunk: string) => consume("stderr", chunk));
+ child.once("error", (error) => { failure = error; });
+ const finish = (code: number | null): void => {
+ if (settled) return; settled = true;
+ clearTimeout(forceClose); clearTimeout(timer); options.signal?.removeEventListener("abort", abort);
+ for (const channel of ["stdout", "stderr"] as const) if (pending[channel]) {
+ options[channel]?.(pending[channel]);
+ if (channel === "stdout") stdout = options.stdout ? pending[channel] : stdout + pending[channel];
+ else stderr = options.stderr ? pending[channel] : stderr + pending[channel];
+ }
+ if (failure) reject(failure); else resolve({ code: code ?? 1, stdout, stderr });
+ };
+ child.once("close", finish);
+});
diff --git a/src/compiler/training/context.test.ts b/src/compiler/training/context.test.ts
new file mode 100644
index 0000000..fcdbbec
--- /dev/null
+++ b/src/compiler/training/context.test.ts
@@ -0,0 +1,154 @@
+import { createHash } from "node:crypto";
+import { mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { stringify } from "yaml";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { createTrainingContext } from "./context.js";
+import { trainingContextJsonSchema, trainingContextSchema } from "./contract.js";
+
+vi.mock("node:fs/promises", async () => {
+ const actual = await vi.importActual("node:fs/promises");
+ return { ...actual, readFile: vi.fn(actual.readFile) };
+});
+const directories: string[] = [];
+const actual = await vi.importActual("node:fs/promises");
+afterEach(async () => {
+ vi.mocked(readFile).mockImplementation(actual.readFile);
+ await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
+});
+const hash = (value: string) => `sha256:${createHash("sha256").update(value).digest("hex")}`;
+
+async function project(team = false): Promise {
+ const root = await realpath(await mkdtemp(path.join(os.tmpdir(), "spawnfile-training-context-")));
+ directories.push(root);
+ await mkdir(path.join(root, "agents/author"), { recursive: true });
+ await mkdir(path.join(root, "skills/reporting"), { recursive: true });
+ await writeFile(path.join(root, "AGENTS.md"), "Root system\n");
+ await writeFile(path.join(root, "SOUL.md"), "Inherited soul\n");
+ await writeFile(path.join(root, "agents/author/AGENTS.md"), "Local author system\n");
+ await writeFile(path.join(root, "skills/reporting/SKILL.md"), "---\nname: reporting\ndescription: Report evidence.\n---\nRead carefully.\n");
+ const workspace = { docs: { system: "AGENTS.md", soul: "SOUL.md" }, skills: [{ ref: "./skills/reporting" }] };
+ const declaration = team ? {
+ spawnfile_version: "0.1", kind: "team", name: "publication", mode: "hierarchical", lead: "author",
+ shared: { workspace, environment: { env: { SECRET_LIKE_VALUE: "must-not-be-in-receipt" } } },
+ members: [{ id: "author", ref: "./agents/author" }, { id: "reviewer", runtime: "daimon", workspace: { docs: { system: "AGENTS.md" } } }]
+ } : { spawnfile_version: "0.1", kind: "agent", name: "author", runtime: "daimon", workspace };
+ await writeFile(path.join(root, "Spawnfile"), stringify(declaration));
+ if (team) await writeFile(path.join(root, "agents/author/Spawnfile"), stringify({
+ spawnfile_version: "0.1", kind: "agent", name: "author",
+ runtime: { name: "daimon", options: { engine: "codex" } },
+ execution: { model: { primary: { provider: "openai", name: "gpt-5.5" }, auth: { method: "codex" } } },
+ workspace: { docs: { system: "AGENTS.md" }, resources: [
+ { id: "source", kind: "git", url: "https://user:private-token@example.invalid/code.git", ref: "a".repeat(40), mount: "./source", mode: "readonly" },
+ { id: "moving", kind: "git", url: "https://example.invalid/other.git", branch: "main", mount: "./moving", mode: "readonly" },
+ { id: "tools", kind: "bundle", source: "missing-but-uncompiled.tar", sha256: hash("tools"), mount: "./tools", mode: "readonly" },
+ { id: "state", kind: "volume", name: "never-attach", mount: "./state", mode: "mutable", sharing: "team" }
+ ] }
+ }));
+ return root;
+}
+
+describe("canonical training context", () => {
+ it("pins the full graph and preserves effective inherited docs, skills and model auth", async () => {
+ const root = await project(true);
+ const context = await createTrainingContext(root, { agent: "agent:author", packageVersion: "0.1.17" });
+ expect(context.agent).toEqual({ id: "agent:author", name: "author", source: path.join(root, "agents/author/Spawnfile"),
+ runtime: "daimon", engine: "codex", model: { provider: "openai", name: "gpt-5.5", authMethod: "codex" } });
+ expect(context.documents.map((document) => [document.role, document.destinationPath])).toEqual([
+ ["system", "agents/author/AGENTS.md"], ["soul", "SOUL.md"]
+ ]);
+ expect(context.skills[0]).toMatchObject({ name: "reporting", destinationPath: "skills/reporting/SKILL.md" });
+ expect(context.sources.map((source) => source.destinationPath)).toEqual([
+ "AGENTS.md", "SOUL.md", "Spawnfile", "agents/author/AGENTS.md", "agents/author/Spawnfile", "skills/reporting/SKILL.md"
+ ]);
+ expect(context.project.sourceDigest).toBe(hash(JSON.stringify(context.sources.map(({ destinationPath, sha256 }) => ({ destinationPath, sha256 })))));
+ expect(context.resources.map((resource) => [resource.id, resource.pin])).toEqual([
+ ["moving", null], ["source", "a".repeat(40)], ["state", null], ["tools", hash("tools")]
+ ]);
+ expect(JSON.stringify(context)).not.toMatch(/must-not-be-in-receipt|private-token|never-attach|example\.invalid/u);
+ expect(context.requirements).toEqual({ nativeCompilation: true, isolatedPreparation: true });
+ expect(await actual.readdir(root)).not.toContain(".spawn");
+ });
+
+ it("infers only a single agent and retains unknown runtime defaults", async () => {
+ const context = await createTrainingContext(await project(), { packageVersion: "0.1.17" });
+ expect(context.agent).toMatchObject({ id: "agent:author", engine: null, model: null });
+ expect(trainingContextSchema.parse(context)).toEqual(context);
+ expect(trainingContextJsonSchema).toMatchObject({ type: "object", additionalProperties: false });
+ expect(trainingContextSchema.safeParse({ ...context, environment: { secret: "no" } }).success).toBe(false);
+ expect(trainingContextSchema.safeParse({ ...context, sources: [{ ...context.sources[0], destinationPath: "../escape" }] }).success).toBe(false);
+ });
+
+ it("selects an inline member through its actual parent manifest", async () => {
+ const root = await project(true);
+ const context = await createTrainingContext(root, { agent: "agent:reviewer", packageVersion: "0.1.17" });
+ expect(context.agent.source).toBe(path.join(root, "Spawnfile"));
+ expect(context.documents.find((document) => document.role === "system")?.destinationPath).toBe("AGENTS.md");
+ expect(context.resources).toEqual([]);
+ });
+
+ it("rejects omitted, fuzzy, team and unknown selections in a multi-agent graph", async () => {
+ const root = await project(true);
+ for (const agent of [undefined, "author", "team:publication", "agent:missing"]) {
+ await expect(createTrainingContext(root, { agent, packageVersion: "0.1.17" })).rejects.toThrow(/--agent|No canonical agent/u);
+ }
+ });
+
+ it("makes changed source bytes visible in the fingerprint without absolute-root dependence", async () => {
+ const root = await project();
+ const initial = await createTrainingContext(root, { packageVersion: "0.1.17" });
+ const clone = await project();
+ expect((await createTrainingContext(clone, { packageVersion: "0.1.17" })).project.sourceDigest).toBe(initial.project.sourceDigest);
+ await writeFile(path.join(root, "AGENTS.md"), "Updated system\n");
+ const updated = await createTrainingContext(root, { packageVersion: "0.1.17" });
+ expect(updated.project.sourceDigest).not.toBe(initial.project.sourceDigest);
+ expect(updated.documents.find((document) => document.role === "system")?.sha256).toBe(hash("Updated system\n"));
+ });
+
+ it("rejects a symlink escaping the project", async () => {
+ const root = await project(), outside = await project();
+ await rm(path.join(root, "AGENTS.md"));
+ await symlink(path.join(outside, "AGENTS.md"), path.join(root, "AGENTS.md"));
+ await expect(createTrainingContext(root, { packageVersion: "0.1.17" })).rejects.toThrow(/inside the canonical project root|Symlinks are not allowed/u);
+ });
+
+ it("rejects a referenced agent outside the canonical project root", async () => {
+ const root = await project(), outside = await project();
+ await writeFile(path.join(root, "Spawnfile"), stringify({ spawnfile_version: "0.1", kind: "team", name: "external", mode: "swarm",
+ members: [{ id: "author", ref: path.relative(root, outside) }] }));
+ await expect(createTrainingContext(root, { packageVersion: "0.1.17" })).rejects.toThrow(/inside the canonical project root|escapes/u);
+ });
+
+ it.each(["AGENTS.md", "Spawnfile"])("rejects %s changing during capture", async (name) => {
+ const root = await project();
+ let reads = 0;
+ const target = path.join(root, name);
+ vi.mocked(readFile).mockImplementation((async (...args: Parameters) => {
+ if (String(args[0]) === target && ++reads === (name === "Spawnfile" ? 3 : 2)) {
+ await writeFile(target, name === "Spawnfile" ? `${await actual.readFile(target, "utf8")}# changed\n` : "Changed during resolution\n");
+ }
+ return actual.readFile(...args);
+ }) as typeof readFile);
+ await expect(createTrainingContext(root, { packageVersion: "0.1.17" })).rejects.toThrow(/changed/u);
+ });
+
+ it("rejects a manifest changed before its first pin instead of pairing old resolution with new bytes", async () => {
+ const root = await project(), target = path.join(root, "Spawnfile");
+ let reads = 0;
+ vi.mocked(readFile).mockImplementation((async (...args: Parameters) => {
+ if (String(args[0]) === target && ++reads === 2) {
+ await writeFile(target, (await actual.readFile(target, "utf8")).replace("name: author", "name: changed"));
+ }
+ return actual.readFile(...args);
+ }) as typeof readFile);
+ await expect(createTrainingContext(root, { packageVersion: "0.1.17" })).rejects.toThrow("between canonical resolution");
+ });
+
+ it("keeps the documented JSON schema identical to the exported contract", async () => {
+ const doc = await actual.readFile(new URL("../../../specs/TRAINING.md", import.meta.url), "utf8");
+ const schema = doc.match(/\n```json\n([\s\S]*?)\n```/u)?.[1];
+ expect(JSON.parse(schema!)).toEqual(trainingContextJsonSchema);
+ });
+});
diff --git a/src/compiler/training/context.ts b/src/compiler/training/context.ts
new file mode 100644
index 0000000..999838f
--- /dev/null
+++ b/src/compiler/training/context.ts
@@ -0,0 +1,101 @@
+import { createHash } from "node:crypto";
+import { readFile, realpath, stat } from "node:fs/promises";
+import path from "node:path";
+
+import { SpawnfileError } from "../../shared/index.js";
+import { getManifestPath } from "../../filesystem/index.js";
+import { buildCompilePlan } from "../buildCompilePlan.js";
+import { stableStringify } from "../helpers.js";
+import { resolveEffectiveModelTarget } from "../modelEnv.js";
+import type { CompilePlanNode, ResolvedAgentNode } from "../types.js";
+import { TRAINING_CONTEXT_VERSION, trainingContextSchema, type TrainingContext, type TrainingSource } from "./contract.js";
+
+const sha256 = (value: string | Buffer): string => `sha256:${createHash("sha256").update(value).digest("hex")}`;
+const invalid = (message: string): never => { throw new SpawnfileError("validation_error", message); };
+const inside = (root: string, file: string): boolean => {
+ const relative = path.relative(root, file);
+ return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
+};
+
+export interface CreateTrainingContextOptions {
+ agent?: string;
+ packageVersion: string;
+}
+
+/** Resolves the complete project, preserving inherited selection and source provenance. */
+export const createTrainingContext = async (
+ inputPath: string,
+ options: CreateTrainingContextOptions
+): Promise => {
+ const plan = await buildCompilePlan(await realpath(getManifestPath(inputPath)));
+ const agents = plan.nodes.filter((node): node is CompilePlanNode & { value: ResolvedAgentNode } => node.kind === "agent");
+ const selected = options.agent === undefined
+ ? agents.length === 1 ? agents[0] : undefined
+ : agents.find((node) => node.id === options.agent);
+ if (!selected) invalid(options.agent === undefined
+ ? "Training requires --agent with an exact node id when the project does not contain exactly one agent"
+ : `No canonical agent matches ${options.agent}`);
+ const agent = selected!.value;
+ const manifest = await realpath(plan.root);
+ const root = path.dirname(manifest);
+ const pins = new Map();
+
+ const pin = async (source: string, expectedContent?: string): Promise => {
+ const sourcePath = path.resolve(source);
+ if (!inside(root, sourcePath) || !inside(root, await realpath(sourcePath))) {
+ invalid("Training context v1 requires source files inside the canonical project root");
+ }
+ const info = await stat(sourcePath);
+ if (!info.isFile() || info.size > 16 * 1024 * 1024) invalid("Training source must be a regular file no larger than 16 MiB");
+ const bytes = await readFile(sourcePath);
+ if (expectedContent !== undefined && bytes.toString("utf8") !== expectedContent) {
+ invalid("Training source changed during canonical graph resolution");
+ }
+ const next = { sourcePath, destinationPath: path.relative(root, sourcePath).split(path.sep).join("/"), sha256: sha256(bytes) };
+ const previous = pins.get(sourcePath);
+ if (previous && previous.sha256 !== next.sha256) invalid("Training source changed while its context was captured");
+ pins.set(sourcePath, next);
+ return next;
+ };
+
+ await pin(manifest);
+ // Manifests for inline nodes live at sourcePath; their synthetic node source is not a file.
+ for (const node of plan.nodes) {
+ await pin(node.value.kind === "agent" ? node.value.sourcePath ?? node.value.source : node.value.source);
+ for (const document of node.value.docs) await pin(document.sourcePath, document.content);
+ const skills = node.value.kind === "agent" ? node.value.skills : node.value.shared.skills;
+ for (const skill of skills) await pin(skill.sourcePath, skill.content);
+ }
+ const documents = await Promise.all(agent.docs.map(async (document) => ({
+ ...await pin(document.sourcePath, document.content), role: document.role
+ })));
+ const skills = await Promise.all(agent.skills.map(async (skill) => ({
+ ...await pin(skill.sourcePath, skill.content), name: skill.name, ref: skill.ref, requiresMcp: skill.requiresMcp
+ })));
+ // A manifest can change after graph resolution but before its first pin. Re-resolve
+ // through the compiler owner, then seal all captured bytes against that graph.
+ if (stableStringify(plan) !== stableStringify(await buildCompilePlan(manifest))) {
+ invalid("Training source changed between canonical resolution and provenance capture");
+ }
+ for (const source of [...pins.keys()]) await pin(source);
+ const sources = [...pins.values()].sort((left, right) => left.destinationPath < right.destinationPath ? -1 : left.destinationPath > right.destinationPath ? 1 : 0);
+ const declaredPrimary = agent.execution?.model?.primary;
+ const primary = declaredPrimary ? resolveEffectiveModelTarget(declaredPrimary, agent.execution) : undefined;
+ return trainingContextSchema.parse({
+ version: TRAINING_CONTEXT_VERSION,
+ producer: { package: "spawnfile", version: options.packageVersion },
+ project: { root, manifest, sourceDigest: sha256(JSON.stringify(sources.map(({ destinationPath, sha256: hash }) => ({ destinationPath, sha256: hash })))) },
+ agent: {
+ id: selected!.id, name: agent.name, source: path.resolve(agent.sourcePath ?? agent.source), runtime: agent.runtime.name,
+ engine: typeof agent.runtime.options.engine === "string" ? agent.runtime.options.engine : null,
+ model: primary ? { provider: primary.provider, name: primary.name, authMethod: primary.auth.method } : null
+ },
+ sources, documents, skills,
+ resources: (agent.workspaceResources ?? []).map((resource) => ({
+ id: resource.id, kind: resource.kind, mount: resource.mount, mode: resource.mode, sharing: resource.sharing,
+ definitionDigest: sha256(stableStringify(resource)),
+ pin: resource.kind === "bundle" ? resource.sha256 : resource.kind === "git" ? resource.ref ?? null : null
+ })),
+ requirements: { nativeCompilation: true, isolatedPreparation: true }
+ });
+};
diff --git a/src/compiler/training/contract.ts b/src/compiler/training/contract.ts
new file mode 100644
index 0000000..e2a47da
--- /dev/null
+++ b/src/compiler/training/contract.ts
@@ -0,0 +1,42 @@
+import { z } from "zod";
+
+export const TRAINING_CONTEXT_VERSION = "spawnfile.training-context.v1" as const;
+
+const digest = z.string().regex(/^sha256:[a-f0-9]{64}$/u);
+const absolutePath = z.string().min(1).regex(/^(?:\/|[A-Za-z]:[\\/])/u);
+const relativePath = z.string().min(1).regex(/^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))[^\\]+$/u);
+const text = z.string().min(1);
+
+export const trainingSourceSchema = z.object({
+ sourcePath: absolutePath,
+ destinationPath: relativePath,
+ sha256: digest
+}).strict();
+
+/** Trusted compiler metadata only; this is neither a prompt nor permission to launch. */
+export const trainingContextSchema = z.object({
+ version: z.literal(TRAINING_CONTEXT_VERSION),
+ producer: z.object({ package: z.literal("spawnfile"), version: text }).strict(),
+ project: z.object({ root: absolutePath, manifest: absolutePath, sourceDigest: digest }).strict(),
+ agent: z.object({
+ id: text, name: text, source: absolutePath, runtime: text,
+ engine: text.nullable(),
+ model: z.object({ provider: text, name: text, authMethod: text }).strict().nullable()
+ }).strict(),
+ sources: z.array(trainingSourceSchema).min(1).max(10_000),
+ documents: z.array(trainingSourceSchema.extend({ role: text }).strict()).max(128),
+ skills: z.array(trainingSourceSchema.extend({
+ name: text, ref: text, requiresMcp: z.array(text)
+ }).strict()).max(1_000),
+ resources: z.array(z.object({
+ id: text, kind: z.enum(["bundle", "git", "volume"]), mount: text,
+ mode: z.enum(["mutable", "readonly"]), sharing: z.enum(["per_agent", "team"]),
+ definitionDigest: digest, pin: text.nullable()
+ }).strict()).max(1_000),
+ requirements: z.object({ nativeCompilation: z.literal(true), isolatedPreparation: z.literal(true) }).strict()
+}).strict();
+
+export type TrainingContext = z.infer;
+export type TrainingSource = z.infer;
+
+export const trainingContextJsonSchema = z.toJSONSchema(trainingContextSchema);
diff --git a/src/compiler/training/index.ts b/src/compiler/training/index.ts
new file mode 100644
index 0000000..e7fcf64
--- /dev/null
+++ b/src/compiler/training/index.ts
@@ -0,0 +1,4 @@
+export * from "./contract.js";
+export * from "./context.js";
+export { parseTrainingMappedPreparation } from "./preparation/contract.js";
+export type { TrainingMappedPreparation } from "./preparation/contract.js";
diff --git a/src/compiler/training/preparation/AGENTS.md b/src/compiler/training/preparation/AGENTS.md
new file mode 100644
index 0000000..3286961
--- /dev/null
+++ b/src/compiler/training/preparation/AGENTS.md
@@ -0,0 +1,12 @@
+# Training preparation
+
+Owns the v2 declaration, local pinned inputs, packaged image recipe and verified
+build reuse before the existing container launcher. Never invoke a model, host
+project script or sibling implementation. Project fixture meaning stays in the
+installed integration. Credentials remain explicit runtime leaves, never image
+inputs. Dry-run performs reads only; resume validates the preserved preparation.
+
+`contract.ts` declares authoring and runtime receipts; `files.ts` safely seals
+declared bytes; `image.ts` prepares/builds the recipe; `inputs.ts` snapshots Git;
+`prepare.ts` combines those operations. Keep tests adjacent and files below 400
+lines. Source/lock/recipe mutation must invalidate cache and exact resume.
diff --git a/src/compiler/training/preparation/CLAUDE.md b/src/compiler/training/preparation/CLAUDE.md
new file mode 120000
index 0000000..47dc3e3
--- /dev/null
+++ b/src/compiler/training/preparation/CLAUDE.md
@@ -0,0 +1 @@
+AGENTS.md
\ No newline at end of file
diff --git a/src/compiler/training/preparation/contract.ts b/src/compiler/training/preparation/contract.ts
new file mode 100644
index 0000000..2efe241
--- /dev/null
+++ b/src/compiler/training/preparation/contract.ts
@@ -0,0 +1,61 @@
+import path from "node:path";
+import { z } from "zod";
+import { trainingImageSchema } from "../container/contract.js";
+
+const local = z.string().min(1).refine(value => !/[,\r\n\0]/u.test(value));
+const relative = local.refine(value => !path.isAbsolute(value) && !value.includes("\\") && value.split("/").every(part => part !== "" && part !== "." && part !== ".." && part !== ".git"));
+const reference = z.object({ input: z.string().min(1), path: z.union([z.literal("."), relative]) }).strict();
+const sha = z.string().regex(/^sha256:[a-f0-9]{64}$/u);
+const destination = z.string().regex(/^\/run\/training\/inputs\/[A-Za-z0-9._/-]+$/u).refine(value => path.posix.normalize(value) === value && !value.endsWith("/"));
+export const trainingBuildSchema = z.object({
+ recipe: z.literal("daimon-dspy.v1"), nativeImage: trainingImageSchema, pythonImage: trainingImageSchema,
+ platform: z.enum(["linux/arm64", "linux/amd64"]),
+ paideia: local, bridge: local, claude: local,
+ grok: z.object({ source: local, sha256: sha }).strict(),
+ integration: z.object({ source: local, entry: relative.refine(value => /^[A-Za-z0-9._/-]+$/u.test(value)) }).strict(), bootstrap: local
+}).strict();
+export const trainingPreparationSchema = z.object({
+ version: z.literal("spawnfile.training-container.v2"),
+ dockerContext: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/u),
+ image: z.union([z.object({ ref: trainingImageSchema }).strict(), z.object({ build: trainingBuildSchema }).strict()]),
+ integration: z.object({ settings: reference }).strict(),
+ inputs: z.array(z.object({
+ id: z.string().regex(/^[A-Za-z][A-Za-z0-9_-]{0,63}$/u), source: local, destination,
+ git: z.object({ revision: z.string().regex(/^[a-f0-9]{40}$/u),
+ overlays: z.array(z.object({ source: local, path: relative, sha256: sha }).strict()).max(256).default([])
+ }).strict().optional()
+ }).strict()).min(1).max(64),
+ output: z.object({ source: local, destination: z.literal("/run/training/output") }).strict(),
+ auth: z.array(z.object({ source: local, provider: z.enum(["codex", "grok", "claude"]) }).strict()).max(3)
+}).strict().superRefine((value, context) => {
+ if (new Set(value.inputs.map(input => input.id)).size !== value.inputs.length) context.addIssue({ code: "custom", message: "Input IDs must be unique" });
+ if (new Set(value.auth.map(auth => auth.provider)).size !== value.auth.length) context.addIssue({ code: "custom", message: "Auth providers must be unique" });
+ if (!value.inputs.some(input => input.id === value.integration.settings.input)) context.addIssue({ code: "custom", message: "Integration settings require a declared input" });
+});
+export type TrainingPreparationConfig = z.infer;
+export type TrainingImageBuild = z.infer;
+
+/** Image integration reads this protected, container-addressed receipt, never host paths. */
+export interface TrainingMappedPreparation {
+ version: "spawnfile.training-preparation.v1";
+ preparationDigest: string;
+ imageId: string;
+ bindings: { inputId: string; destination: string }[];
+ outputRoot: "/run/training/output";
+ packagePaths: { spawnfile: string; paideia: string; bridge: string; nativeWorker: string; integration: string; bootstrap: string };
+ integration: { settings: { input: string; path: string } };
+}
+
+const containerPath = z.string().regex(/^\/(?:run|opt)\/[A-Za-z0-9._/-]+$/u).refine(value => path.posix.normalize(value) === value);
+const mappedSchema = z.object({
+ version: z.literal("spawnfile.training-preparation.v1"), preparationDigest: sha, imageId: trainingImageSchema,
+ bindings: z.array(z.object({ inputId: z.string().min(1), destination }).strict()).min(1).max(64),
+ outputRoot: z.literal("/run/training/output"),
+ packagePaths: z.object({ spawnfile: containerPath, paideia: containerPath, bridge: containerPath,
+ nativeWorker: containerPath, integration: containerPath, bootstrap: containerPath }).strict(),
+ integration: z.object({ settings: reference }).strict()
+}).strict().superRefine((value, context) => {
+ if (new Set(value.bindings.map(binding => binding.inputId)).size !== value.bindings.length ||
+ !value.bindings.some(binding => binding.inputId === value.integration.settings.input)) context.addIssue({ code: "custom", message: "Invalid preparation binding IDs" });
+});
+export const parseTrainingMappedPreparation = (value: unknown): TrainingMappedPreparation => mappedSchema.parse(value);
diff --git a/src/compiler/training/preparation/copyAssets.ts b/src/compiler/training/preparation/copyAssets.ts
new file mode 100644
index 0000000..1f28e6e
--- /dev/null
+++ b/src/compiler/training/preparation/copyAssets.ts
@@ -0,0 +1,9 @@
+import { cp, mkdir } from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const root = fileURLToPath(new URL("../../../../", import.meta.url));
+const target = path.join(root, "dist/compiler/training/preparation/assets");
+await mkdir(target, { recursive: true });
+await cp(path.join(root, "runtime-images/training/Dockerfile"), path.join(target, "Dockerfile"));
+await cp(path.join(root, "package-lock.json"), path.join(target, "package-lock.json"));
diff --git a/src/compiler/training/preparation/files.ts b/src/compiler/training/preparation/files.ts
new file mode 100644
index 0000000..9a5cdcb
--- /dev/null
+++ b/src/compiler/training/preparation/files.ts
@@ -0,0 +1,77 @@
+import { createHash } from "node:crypto";
+import { createReadStream } from "node:fs";
+import { copyFile, lstat, mkdir, readdir, realpath, chmod } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+
+export const within = (root: string, file: string): boolean => file === root || file.startsWith(root + path.sep);
+export const hashJson = (value: unknown): string => `sha256:${createHash("sha256").update(JSON.stringify(value)).digest("hex")}`;
+export interface SealedFile { source: string; destination: string; sha256: string; mode: number; size: number }
+const ignored = new Set(["node_modules", ".venv", ".git", "__pycache__", "coverage", ".runtime", "AGENTS.md", "CLAUDE.md"]);
+
+export function assertInputRoot(source: string, auth: readonly string[]): void {
+ const home = os.homedir();
+ if (["/", "/etc", "/var", "/run", "/tmp", "/opt", "/usr", "/Users", "/home", home].includes(source) ||
+ [".codex", ".claude", ".grok", ".ssh", ".config"].some(name => within(path.join(home, name), source)) ||
+ auth.some(leaf => within(source, leaf) || within(leaf, source))) throw Error("Training input exposes a protected host root or auth leaf");
+}
+
+export async function exactPath(source: string): Promise {
+ const absolute = path.resolve(source);
+ if (await realpath(absolute) !== absolute || (await lstat(absolute)).isSymbolicLink()) throw Error("Training source must be canonical, without symlink aliases");
+ return absolute;
+}
+
+export async function sealFile(source: string, destination: string): Promise {
+ await exactPath(source);
+ const before = await lstat(source, { bigint: true });
+ if (!before.isFile() || before.size > 536_870_912n) throw Error("Training source must be a regular file no larger than 512 MiB");
+ const digest = createHash("sha256"); let size = 0;
+ for await (const chunk of createReadStream(source)) {
+ size += chunk.length;
+ if (size > 536_870_912) throw Error("Training source exceeded its size limit");
+ digest.update(chunk);
+ }
+ const after = await lstat(source, { bigint: true });
+ if (BigInt(size) !== before.size || after.size !== before.size || after.ino !== before.ino || after.ctimeNs !== before.ctimeNs || after.dev !== before.dev) throw Error("Training source changed while hashing");
+ return { source, destination, sha256: `sha256:${digest.digest("hex")}`, mode: Number(before.mode & 0o777n), size };
+}
+
+/** Only explicitly selected trees are traversed. Symlinks never enter Docker context. */
+export async function sealTree(source: string, destination: string, options: { ignoreDevelopment?: boolean; ignoreGit?: boolean; internalSymlinks?: boolean } = {}): Promise {
+ await exactPath(source);
+ const files: SealedFile[] = [];
+ const walk = async (root: string, target: string, depth: number): Promise => {
+ if (depth > 32 || files.length > 10000) throw Error("Training source tree exceeds bounds");
+ let stat = await lstat(root);
+ if (stat.isSymbolicLink()) {
+ const actual = await realpath(root);
+ if (!options.internalSymlinks || !within(source, actual)) throw Error("Training source trees must not contain escaping symlinks");
+ root = actual; stat = await lstat(root);
+ }
+ if (stat.isDirectory()) {
+ for (const entry of (await readdir(root)).sort()) {
+ if (options.ignoreGit && entry === ".git") continue;
+ if (options.ignoreDevelopment && (ignored.has(entry) || /(?:\.test\.[cm]?[jt]s|_test\.py|\.pyc)$/u.test(entry))) continue;
+ await walk(path.join(root, entry), path.posix.join(target, entry), depth + 1);
+ }
+ } else files.push(await sealFile(root, target));
+ };
+ await walk(source, destination, 0);
+ if (files.length > 10000 || files.reduce((sum, file) => sum + file.size, 0) > 1_073_741_824) throw Error("Training source tree exceeds bounds");
+ return files;
+}
+
+export async function copySealed(files: readonly SealedFile[], root: string): Promise {
+ for (const file of files) {
+ const target = path.resolve(root, file.destination);
+ if (!within(root, target) || target === root) throw Error("Training destination escapes owned staging");
+ await mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
+ await copyFile(file.source, target, 1);
+ await chmod(target, file.mode);
+ const copied = await sealFile(target, file.destination);
+ if (copied.sha256 !== file.sha256 || copied.size !== file.size) throw Error("Training source changed during staging");
+ }
+}
+
+export const fileIdentity = (files: readonly SealedFile[]) => files.map(({ destination, sha256, mode, size }) => ({ destination, sha256, mode, size }));
diff --git a/src/compiler/training/preparation/fixtures.test-helper.ts b/src/compiler/training/preparation/fixtures.test-helper.ts
new file mode 100644
index 0000000..608507e
--- /dev/null
+++ b/src/compiler/training/preparation/fixtures.test-helper.ts
@@ -0,0 +1,64 @@
+import { mkdtemp, mkdir, readFile, realpath, writeFile } from "node:fs/promises";
+import path from "node:path";
+import os from "node:os";
+import { createHash } from "node:crypto";
+import type { TrainingPreparationConfig } from "./contract.js";
+import type { TrainingContext } from "../contract.js";
+import type { TrainingDockerProcess } from "../container/process.js";
+
+export const image = `sha256:${"a".repeat(64)}`;
+export const sha = (value: string) => `sha256:${createHash("sha256").update(value).digest("hex")}`;
+export async function preparationFixture() {
+ const root = await realpath(await mkdtemp(path.join(os.tmpdir(), "spawnfile-preparation-")));
+ const put = async (file: string, value = "fixture") => { await mkdir(path.dirname(path.join(root, file)), { recursive: true }); await writeFile(path.join(root, file), value); };
+ await put("project/Spawnfile", 'spawnfile_version: "0.1"\nkind: agent\nname: author\nruntime: daimon\n');
+ await put("project/train.yaml"); await put("settings/settings.json", "{}"); await put("auth", "fake-subscription");
+ for (const target of ["own", "paideia", "claude"]) {
+ const manifest = { name: target, version: "1.0.0", dependencies: {} };
+ await put(`${target}/package.json`, JSON.stringify(manifest));
+ await put(`${target}/package-lock.json`, JSON.stringify({ lockfileVersion: 3, packages: { "": manifest } }));
+ }
+ await put("own/dist/cli/index.js"); await put("own/runtimes.yaml"); await put("own/moltnet-releases.json");
+ await put("own/runtime-images/training/Dockerfile", "ARG NATIVE_IMAGE\nFROM ${NATIVE_IMAGE}\nCOPY train /opt/training/bin/train\n");
+ await put("paideia/dist/src/cli/main.js"); await put("bridge/pyproject.toml"); await put("bridge/requirements.lock");
+ await put("bridge/paideia_dspy/__init__.py"); await put("integration/entry.ts"); await put("bootstrap/start.ts"); await put("grok", "native-binary");
+ const config: TrainingPreparationConfig = {
+ version: "spawnfile.training-container.v2", dockerContext: "local",
+ image: { build: { recipe: "daimon-dspy.v1", nativeImage: image, pythonImage: image, platform: "linux/arm64",
+ paideia: "paideia", bridge: "bridge", claude: "claude", grok: { source: "grok", sha256: sha("native-binary") },
+ integration: { source: "integration", entry: "entry.ts" }, bootstrap: "bootstrap" } },
+ integration: { settings: { input: "settings", path: "settings.json" } },
+ inputs: [{ id: "project", source: "project", destination: "/run/training/inputs/project" }, { id: "settings", source: "settings", destination: "/run/training/inputs/settings" }],
+ output: { source: "output", destination: "/run/training/output" }, auth: [{ source: "auth", provider: "claude" }]
+ };
+ const sourcePath = path.join(root, "project/Spawnfile");
+ const source = { sourcePath, destinationPath: "Spawnfile", sha256: sha(await readFile(sourcePath, "utf8")) };
+ const context: TrainingContext = { version: "spawnfile.training-context.v1", producer: { package: "spawnfile", version: "test" },
+ project: { root: path.join(root, "project"), manifest: sourcePath, sourceDigest: source.sha256 },
+ agent: { id: "agent:author", name: "author", source: sourcePath, runtime: "daimon", engine: null, model: null },
+ sources: [source], documents: [], skills: [], resources: [], requirements: { nativeCompilation: true, isolatedPreparation: true } };
+ const configPath = path.join(root, "training.json");
+ const save = () => writeFile(configPath, JSON.stringify(config)); await save();
+ return { root, config, configPath, context, put, save,
+ args: ["--train", path.join(root, "project/train.yaml"), "--out", path.join(root, "output")] };
+}
+
+export function imageDocker() {
+ const calls: string[][] = []; const images = new Map(); let builtContext: string | undefined;
+ const process: TrainingDockerProcess = async args => {
+ calls.push([...args]);
+ if (args[0] === "context") return { code: 0, stdout: JSON.stringify("unix:///socket"), stderr: "" };
+ if (args[2] === "build") {
+ builtContext = args.at(-1)!;
+ const dockerfile = await readFile(path.join(builtContext, "Dockerfile"), "utf8");
+ const digest = JSON.parse(dockerfile.split("LABEL com.spawnfile.training.recipe=")[1]!.trim());
+ images.set(args[args.indexOf("--tag") + 1]!, digest);
+ return { code: 0, stdout: "built", stderr: "" };
+ }
+ const target = args[4]!;
+ if (target === image) return { code: 0, stdout: image, stderr: "" };
+ const digest = images.get(target);
+ return digest ? { code: 0, stdout: `${JSON.stringify(image)}\n${JSON.stringify(digest)}`, stderr: "" } : { code: 1, stdout: "", stderr: "missing" };
+ };
+ return { calls, images, process, get builtContext() { return builtContext; } };
+}
diff --git a/src/compiler/training/preparation/image.ts b/src/compiler/training/preparation/image.ts
new file mode 100644
index 0000000..6ed66f1
--- /dev/null
+++ b/src/compiler/training/preparation/image.ts
@@ -0,0 +1,83 @@
+import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import type { TrainingDockerProcess } from "../container/process.js";
+import type { TrainingImageBuild } from "./contract.js";
+import { assertInputRoot, copySealed, fileIdentity, hashJson, sealFile, sealTree, type SealedFile } from "./files.js";
+
+const packageRoot = fileURLToPath(new URL("../../../../", import.meta.url));
+export const trainingAssets = path.extname(fileURLToPath(import.meta.url)) === ".ts"
+ ? path.join(packageRoot, "runtime-images/training") : fileURLToPath(new URL("./assets/", import.meta.url));
+export interface TrainingImagePlan { digest: string; files: SealedFile[]; dockerfile: string; entry: string; build: TrainingImageBuild }
+
+async function packageFiles(root: string, target: string, withDist: boolean, lockSource = path.join(root, "package-lock.json")): Promise {
+ const manifest = JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
+ const lock = JSON.parse(await readFile(lockSource, "utf8"));
+ if (![2, 3].includes(lock.lockfileVersion) || !lock.packages?.[""] ||
+ JSON.stringify(manifest.dependencies ?? {}) !== JSON.stringify(lock.packages[""].dependencies ?? {})) throw Error(`Training package manifest/lock mismatch: ${target}`);
+ if (Object.values(lock.packages).some(entry => {
+ const item = entry as { link?: boolean; resolved?: string };
+ return item.link || item.resolved?.startsWith("file:") || item.resolved?.startsWith("../");
+ })) throw Error(`Training package ${target} has unsupported local dependencies; provide a complete registry-locked distribution`);
+ return [await sealFile(path.join(root, "package.json"), `${target}/package.json`),
+ await sealFile(lockSource, `${target}/package-lock.json`),
+ ...withDist ? await sealTree(path.join(root, "dist"), `${target}/dist`, { ignoreDevelopment: true }) : []];
+}
+
+export async function planTrainingImage(build: TrainingImageBuild, root: string, auth: readonly string[], ownRoot = packageRoot): Promise {
+ const resolve = (value: string) => path.resolve(root, value);
+ for (const source of [build.paideia, build.bridge, build.claude, build.grok.source, build.integration.source, build.bootstrap]) assertInputRoot(resolve(source), auth);
+ const assets = ownRoot === packageRoot ? trainingAssets : path.join(ownRoot, "runtime-images/training");
+ const dockerfile = await readFile(path.join(assets, "Dockerfile"), "utf8");
+ const ownLock = path.extname(fileURLToPath(import.meta.url)) === ".ts" || ownRoot !== packageRoot
+ ? path.join(ownRoot, "package-lock.json") : path.join(assets, "package-lock.json");
+ const files = [
+ ...await packageFiles(resolve(build.paideia), "paideia", true),
+ ...await packageFiles(ownRoot, "spawnfile", true, ownLock),
+ ...await packageFiles(resolve(build.claude), "claude", false),
+ ...await sealTree(resolve(build.bridge), "bridge", { ignoreDevelopment: true }),
+ ...await sealTree(resolve(build.integration.source), "integration", { ignoreDevelopment: true }),
+ ...await sealTree(resolve(build.bootstrap), "bootstrap", { ignoreDevelopment: true }),
+ await sealFile(resolve(build.grok.source), "grok"),
+ await sealFile(path.join(ownRoot, "runtimes.yaml"), "spawnfile/runtimes.yaml"),
+ await sealFile(path.join(ownRoot, "moltnet-releases.json"), "spawnfile/moltnet-releases.json")
+ ];
+ if (files.find(file => file.destination === "grok")!.sha256 !== build.grok.sha256) throw Error("Grok executable digest mismatch");
+ for (const required of [`integration/${build.integration.entry}`, "bridge/pyproject.toml", "bridge/requirements.lock", "bridge/paideia_dspy/__init__.py", "paideia/dist/src/cli/main.js", "spawnfile/dist/cli/index.js"]) {
+ if (!files.some(file => file.destination === required)) throw Error(`Training distribution is missing ${required}`);
+ }
+ const entry = `#!/bin/sh\nexec /usr/local/bin/node --experimental-strip-types ${JSON.stringify(`/opt/training/integration/${build.integration.entry}`)} "$@"\n`;
+ const digest = hashJson({ recipe: build.recipe, nativeImage: build.nativeImage, pythonImage: build.pythonImage, platform: build.platform,
+ files: fileIdentity(files), dockerfile, entry });
+ return { digest, files, dockerfile, entry, build };
+}
+
+/** Cache is content-addressed and still requires a matching immutable image and label. */
+export async function buildTrainingImage(plan: TrainingImagePlan, options: {
+ parent: string; dockerContext: string; process: TrainingDockerProcess; timeoutMs: number; signal?: AbortSignal;
+ streams: { stdout(line: string): void; stderr(line: string): void };
+}): Promise<{ imageId: string; cached: boolean }> {
+ const tag = `spawnfile-training:${plan.digest.slice(7)}`;
+ const call = (args: string[], stream = false) => options.process(["--context", options.dockerContext, ...args], {
+ timeoutMs: options.timeoutMs, signal: options.signal, ...stream ? options.streams : {}
+ });
+ const inspect = async (): Promise => {
+ const result = await call(["image", "inspect", tag, "--format", '{{json .Id}}\n{{json (index .Config.Labels "com.spawnfile.training.recipe")}}']);
+ if (result.code !== 0) return undefined;
+ try { const [id, digest] = result.stdout.trim().split("\n").map(line => JSON.parse(line));
+ return /^sha256:[a-f0-9]{64}$/u.test(id) && digest === plan.digest ? id : undefined;
+ } catch { return undefined; }
+ };
+ const cached = await inspect(); if (cached) return { imageId: cached, cached: true };
+ const staging = await mkdtemp(path.join(options.parent, ".spawnfile-training-image-"));
+ try {
+ await copySealed(plan.files, staging);
+ await writeFile(path.join(staging, "Dockerfile"), `${plan.dockerfile}\nLABEL com.spawnfile.training.recipe=${JSON.stringify(plan.digest)}\n`, { mode: 0o600 });
+ await writeFile(path.join(staging, "train"), plan.entry, { mode: 0o755 });
+ const result = await call(["build", "--platform", plan.build.platform, "--build-arg", `NATIVE_IMAGE=${plan.build.nativeImage}`,
+ "--build-arg", `PYTHON_IMAGE=${plan.build.pythonImage}`, "--tag", tag, staging], true);
+ if (result.code !== 0) throw Error("Training image build failed; inspect the streamed build diagnostic");
+ const imageId = await inspect(); if (!imageId) throw Error("Training image build did not produce a verified immutable image");
+ return { imageId, cached: false };
+ } finally { await rm(staging, { recursive: true, force: true }); }
+}
diff --git a/src/compiler/training/preparation/index.ts b/src/compiler/training/preparation/index.ts
new file mode 100644
index 0000000..1352a64
--- /dev/null
+++ b/src/compiler/training/preparation/index.ts
@@ -0,0 +1,3 @@
+export { prepareTraining } from "./prepare.js";
+export { trainingPreparationSchema, parseTrainingMappedPreparation } from "./contract.js";
+export type { TrainingMappedPreparation, TrainingPreparationConfig } from "./contract.js";
diff --git a/src/compiler/training/preparation/inputs.ts b/src/compiler/training/preparation/inputs.ts
new file mode 100644
index 0000000..9cc894c
--- /dev/null
+++ b/src/compiler/training/preparation/inputs.ts
@@ -0,0 +1,66 @@
+import { execFile } from "node:child_process";
+import { mkdir, readFile } from "node:fs/promises";
+import path from "node:path";
+import { promisify } from "node:util";
+import { pathToFileURL } from "node:url";
+import type { TrainingPreparationConfig } from "./contract.js";
+import { assertInputRoot, copySealed, exactPath, fileIdentity, hashJson, sealFile, sealTree, type SealedFile } from "./files.js";
+
+const execute = promisify(execFile);
+const git = async (cwd: string, args: string[]) => (await execute("git", args, { cwd, timeout: 120000, maxBuffer: 8 * 1024 * 1024 })).stdout;
+export interface PlannedInput {
+ id: string; source: string; destination: string; digest: string;
+ git?: { revision: string; tree: string; common: string; overlays: SealedFile[] };
+}
+
+export async function planInputs(config: TrainingPreparationConfig, root: string, auth: string[]): Promise {
+ return Promise.all(config.inputs.map(async input => {
+ const source = await exactPath(path.resolve(root, input.source));
+ assertInputRoot(source, auth);
+ if (!input.git) return { id: input.id, source, destination: input.destination, digest: hashJson(fileIdentity(await sealTree(source, "input", { ignoreGit: true, internalSymlinks: true }))) };
+ if ((await git(source, ["rev-parse", "--show-prefix"])).trim()) throw Error("Pinned Git input source must be a repository root");
+ const common = await exactPath((await git(source, ["rev-parse", "--path-format=absolute", "--git-common-dir"])).trim());
+ const tree = (await git(source, ["rev-parse", `${input.git.revision}^{tree}`])).trim();
+ const entries = (await git(source, ["ls-tree", "-rz", "--full-tree", input.git.revision])).split("\0").filter(Boolean);
+ if (!/^[a-f0-9]{40}$/u.test(tree) || entries.some(entry => !/^(100644|100755) blob [a-f0-9]{40}\t/u.test(entry))) throw Error("Pinned Git inputs require regular files; symlinks and submodules are unsupported");
+ const overlays: SealedFile[] = [];
+ for (const overlay of input.git.overlays) {
+ const overlaySource = path.resolve(root, overlay.source); assertInputRoot(overlaySource, auth);
+ const file = await sealFile(overlaySource, overlay.path);
+ if (file.sha256 !== overlay.sha256 || overlays.some(previous => previous.destination === file.destination)) throw Error("Git overlay digest mismatch or duplicate destination");
+ overlays.push(file);
+ }
+ return { id: input.id, source, destination: input.destination, git: { revision: input.git.revision, tree, common, overlays },
+ digest: hashJson({ revision: input.git.revision, tree, overlays: fileIdentity(overlays) }) };
+ }));
+}
+
+/** A real self-contained Git object store, never a copied worktree pointer. */
+export async function stageInput(input: PlannedInput, target: string): Promise {
+ if (!input.git) return input.source;
+ await mkdir(target, { mode: 0o700 });
+ await git(target, ["init", "--quiet"]);
+ await git(target, ["-c", "protocol.file.allow=always", "fetch", "--quiet", "--depth=1", pathToFileURL(input.git.common).href, input.git.revision]);
+ await git(target, ["-c", "advice.detachedHead=false", "checkout", "--quiet", "--detach", input.git.revision]);
+ if ((await git(target, ["rev-parse", "HEAD^{tree}"])).trim() !== input.git.tree ||
+ (await git(target, ["rev-parse", "--path-format=absolute", "--git-common-dir"])).trim() !== path.join(target, ".git")) throw Error("Git training snapshot identity mismatch");
+ await git(target, ["fsck", "--full", "--no-dangling"]);
+ await copySealed(input.git.overlays, target);
+ return target;
+}
+
+export async function verifyCanonicalPins(inputs: PlannedInput[], sources: readonly { sourcePath: string; sha256: string }[], staged: readonly string[]): Promise {
+ for (const source of sources) {
+ const index = inputs.findIndex(input => source.sourcePath === input.source || source.sourcePath.startsWith(input.source + path.sep));
+ if (index < 0) throw Error("Canonical training source is outside declared inputs");
+ if (!inputs[index]!.git) continue;
+ const file = path.join(staged[index]!, path.relative(inputs[index]!.source, source.sourcePath));
+ if ((await sealFile(file, "pin")).sha256 !== source.sha256) throw Error("Pinned Git snapshot differs from the selected canonical agent");
+ }
+}
+
+export async function readBoundedJson(file: string): Promise {
+ const source = await readFile(file, "utf8");
+ if (Buffer.byteLength(source) > 1024 * 1024) throw Error("Training preparation JSON exceeds 1 MiB");
+ return JSON.parse(source);
+}
diff --git a/src/compiler/training/preparation/prepare.test.ts b/src/compiler/training/preparation/prepare.test.ts
new file mode 100644
index 0000000..dc30b1d
--- /dev/null
+++ b/src/compiler/training/preparation/prepare.test.ts
@@ -0,0 +1,91 @@
+import { execFile } from "node:child_process";
+import { lstat, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
+import path from "node:path";
+import { promisify } from "node:util";
+import { afterEach, expect, it } from "vitest";
+import { prepareTraining } from "./prepare.js";
+import { preparationFixture, imageDocker, image } from "./fixtures.test-helper.js";
+import { parseTrainingMappedPreparation } from "./contract.js";
+
+const roots: string[] = [];
+afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); });
+async function fixture() { const f = await preparationFixture(); roots.push(f.root); const docker = imageDocker();
+ return { ...f, docker, options: { configPath: f.configPath, context: f.context, args: f.args, dryRun: false,
+ process: docker.process, timeoutMs: 30000, packageRoot: path.join(f.root, "own"), streams: { stdout() {}, stderr() {} } } }; }
+
+it("prepares cold and warm runs, preserves mapped identity and resumes without rebuilding", async () => {
+ const f = await fixture();
+ const first = await prepareTraining(f.options); if ("dryRun" in first) throw Error("actual preparation expected");
+ expect(first.image).toBe(image);
+ expect((await lstat(path.join(f.root, "output"))).isDirectory()).toBe(true);
+ const mapped = parseTrainingMappedPreparation(JSON.parse(await readFile(first.preparationPath, "utf8")));
+ expect(mapped.bindings).toEqual([{ inputId: "project", destination: "/run/training/inputs/project" }, { inputId: "settings", destination: "/run/training/inputs/settings" }]);
+ expect(JSON.stringify(mapped)).not.toContain(f.root);
+ const resumed = await prepareTraining({ ...f.options, args: [...f.args, "--resume"] });
+ expect(resumed).toEqual({ ...first, args: [...first.args, "--resume"] });
+ f.config.output.source = "second"; await f.save();
+ await prepareTraining({ ...f.options, args: ["--train", f.args[1]!, "--out", path.join(f.root, "second")] });
+ expect(f.docker.calls.filter(args => args[2] === "build")).toHaveLength(1);
+ expect(await readdir(f.root)).not.toContain(path.basename(f.docker.builtContext!));
+});
+
+it("dry-run reads declarations without Docker, auth access, output or staging mutations", async () => {
+ const f = await fixture(); f.config.auth[0]!.source = "missing-auth"; await f.save();
+ const before = await readdir(f.root);
+ expect(await prepareTraining({ ...f.options, dryRun: true })).toMatchObject({ dryRun: true });
+ expect(f.docker.calls).toEqual([]); expect(await readdir(f.root)).toEqual(before);
+});
+
+it("rejects changed executable, fixture, declaration and saved image on exact resume", async () => {
+ const f = await fixture(); await prepareTraining(f.options);
+ await f.put("paideia/dist/src/cli/main.js", "changed");
+ await expect(prepareTraining({ ...f.options, args: [...f.args, "--resume"] })).rejects.toThrow("changed");
+ await f.put("paideia/dist/src/cli/main.js"); await f.put("project/train.yaml", "changed");
+ await expect(prepareTraining({ ...f.options, args: [...f.args, "--resume"] })).rejects.toThrow("changed");
+ await f.put("project/train.yaml");
+ await expect(prepareTraining({ ...f.options, args: [...f.args, "--resume"], process: async args => args[0] === "context"
+ ? { code: 0, stdout: '"unix:///socket"', stderr: "" } : { code: 1, stdout: "", stderr: "" } })).rejects.toThrow("image");
+});
+
+it("materializes a real pinned worktree with overlays and rejects snapshot tampering", async () => {
+ const f = await fixture(), git = promisify(execFile), project = f.context.project.root;
+ const run = (args: string[]) => git("git", args, { cwd: project });
+ await run(["init", "-q"]); await run(["add", "."]);
+ await run(["-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "-qm", "seed"]);
+ const revision = (await run(["rev-parse", "HEAD"])).stdout.trim();
+ const { sha } = await import("./fixtures.test-helper.js");
+ await f.put("overlay", "generated"); f.config.inputs[0]!.git = { revision, overlays: [{ source: "overlay", path: "tools.tar", sha256: sha("generated") }] }; await f.save();
+ const prepared = await prepareTraining(f.options); if ("dryRun" in prepared) throw Error("actual expected");
+ expect(prepared.context.project.root).not.toBe(project);
+ expect(await readFile(path.join(prepared.context.project.root, "tools.tar"), "utf8")).toBe("generated");
+ expect((await lstat(path.join(prepared.context.project.root, ".git"))).isDirectory()).toBe(true);
+ await prepareTraining({ ...f.options, args: [...f.args, "--resume"] });
+ await writeFile(path.join(prepared.context.project.root, "tools.tar"), "tampered");
+ await expect(prepareTraining({ ...f.options, args: [...f.args, "--resume"] })).rejects.toThrow("snapshot changed");
+});
+
+it("rejects auth/input overlap, missing auth, unsafe context and output mismatch before launch", async () => {
+ const f = await fixture(); f.config.auth[0]!.source = "project/Spawnfile"; await f.save();
+ await expect(prepareTraining(f.options)).rejects.toThrow("auth leaf"); expect(f.docker.calls).toEqual([]);
+ f.config.auth[0]!.source = "missing"; await f.save(); await expect(prepareTraining(f.options)).rejects.toThrow();
+ f.config.auth[0]!.source = "auth"; f.config.output.source = "project/run"; await f.save();
+ await expect(prepareTraining(f.options)).rejects.toThrow("overlaps");
+ f.config.output.source = "output"; await f.save();
+ await expect(prepareTraining({ ...f.options, process: async () => ({ code: 0, stdout: '"tcp://remote"', stderr: "" }) })).rejects.toThrow("Unix");
+ await expect(prepareTraining({ ...f.options, args: ["--out", "/different"] })).rejects.toThrow("match configured output");
+});
+
+it("rejects package symlinks, unsupported local locks and malformed Grok pins before building", async () => {
+ const f = await fixture();
+ await symlink(path.join(f.root, "auth"), path.join(f.root, "integration", "leak"));
+ await expect(prepareTraining(f.options)).rejects.toThrow("symlinks"); await rm(path.join(f.root, "integration", "leak"));
+ await f.put("claude/package-lock.json", JSON.stringify({ lockfileVersion: 3, packages: { "": { dependencies: {} }, "node_modules/local": { link: true } } }));
+ await expect(prepareTraining(f.options)).rejects.toThrow("unsupported local");
+ expect(f.docker.calls).toEqual([]);
+});
+
+it("uses an explicit immutable image without package preparation", async () => {
+ const f = await fixture(); f.config.image = { ref: image }; await f.save();
+ const result = await prepareTraining(f.options); expect(result).toMatchObject({ image });
+ expect(f.docker.calls.some(args => args[2] === "build")).toBe(false);
+});
diff --git a/src/compiler/training/preparation/prepare.ts b/src/compiler/training/preparation/prepare.ts
new file mode 100644
index 0000000..ddf2311
--- /dev/null
+++ b/src/compiler/training/preparation/prepare.ts
@@ -0,0 +1,107 @@
+import { lstat, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
+import path from "node:path";
+import type { TrainingContext } from "../contract.js";
+import { trainingContainerConfigSchema } from "../container/contract.js";
+import type { TrainingDockerProcess } from "../container/process.js";
+import { trainingPreparationSchema, parseTrainingMappedPreparation, type TrainingMappedPreparation } from "./contract.js";
+import { assertInputRoot, exactPath, fileIdentity, hashJson, sealTree, within } from "./files.js";
+import { planInputs, readBoundedJson, stageInput, verifyCanonicalPins } from "./inputs.js";
+import { planTrainingImage, buildTrainingImage } from "./image.js";
+
+export interface PrepareTrainingOptions {
+ configPath: string; context: TrainingContext; args: readonly string[]; dryRun: boolean;
+ process: TrainingDockerProcess; timeoutMs: number; signal?: AbortSignal;
+ streams: { stdout(line: string): void; stderr(line: string): void };
+ /** Test-only package fixture; production always resolves its own installed distribution. */
+ packageRoot?: string;
+}
+export interface PreparedTraining {
+ digest: string; image: string; configPath: string; preparationPath: string; context: TrainingContext; args: string[];
+}
+
+/** Reads only until the explicit dry-run boundary; preparation never executes project code. */
+export async function prepareTraining(options: PrepareTrainingOptions): Promise {
+ const config = trainingPreparationSchema.parse(await readBoundedJson(options.configPath));
+ const root = path.dirname(path.resolve(options.configPath));
+ const auth = config.auth.map(entry => ({ ...entry, source: path.resolve(root, entry.source) }));
+ const output = path.resolve(root, config.output.source), parent = path.dirname(output);
+ assertInputRoot(output, auth.map(entry => entry.source));
+ if (await realpath(parent) !== parent) throw Error("Training output parent must be canonical and already exist");
+ const inputs = await planInputs(config, root, auth.map(entry => entry.source));
+ const roots = inputs.map(input => input.source);
+ for (let index = 0; index < inputs.length; index++) {
+ const input = inputs[index]!;
+ if (within(input.source, output) || within(output, input.source)) throw Error("Training output overlaps readonly input");
+ for (const other of inputs.slice(index + 1)) if (within(input.source, other.source) || within(other.source, input.source) ||
+ within(input.destination, other.destination) || within(other.destination, input.destination)) throw Error("Training inputs overlap");
+ }
+ const imagePlan = "build" in config.image ? await planTrainingImage(config.image.build, root, auth.map(entry => entry.source), options.packageRoot) : undefined;
+ const digest = hashJson({ config, sources: inputs.map(input => ({ id: input.id, digest: input.digest })), image: imagePlan?.digest ?? config.image,
+ canonical: options.context.project.sourceDigest });
+ if (options.dryRun) return { digest, dryRun: true };
+ options.signal?.throwIfAborted();
+ for (const entry of auth) if (await exactPath(entry.source) !== entry.source || !(await lstat(entry.source)).isFile()) throw Error("Training auth must be a canonical regular leaf");
+ const execute = (args: string[]) => options.process(args, { timeoutMs: options.timeoutMs, signal: options.signal });
+ const endpoint = await execute(["context", "inspect", config.dockerContext, "--format", "{{json .Endpoints.docker.Host}}"]);
+ if (endpoint.code !== 0 || !/^unix:\/\//u.test(JSON.parse(endpoint.stdout))) throw Error("Training preparation requires a local Unix Docker context");
+ const staging = path.join(parent, `.spawnfile-training-preparation-${hashJson(output).slice(7, 23)}`);
+ const configPath = path.join(staging, "launch.json"), preparationPath = path.join(staging, "mapped.json"), statePath = path.join(staging, "state.json");
+ const resume = options.args.includes("--resume");
+ let image: string, staged: string[];
+ if (resume) {
+ const previous = JSON.parse(await readFile(statePath, "utf8")) as { digest: string; image: string; staged: string[]; snapshots: (string | null)[] };
+ if (previous.digest !== digest || !/^sha256:[a-f0-9]{64}$/u.test(previous.image) ||
+ JSON.stringify(previous.staged) !== JSON.stringify(inputs.map(input => input.git ? path.join(staging, input.id) : input.source))) throw Error("Training preparation changed; exact resume rejected");
+ image = previous.image; staged = previous.staged;
+ const snapshots = await Promise.all(inputs.map(async (input, index) => input.git ? hashJson(fileIdentity(await sealTree(staged[index]!, "input", { ignoreGit: true }))) : null));
+ if (JSON.stringify(previous.snapshots) !== JSON.stringify(snapshots)) throw Error("Persisted training input snapshot changed");
+ const mapped = parseTrainingMappedPreparation(await readBoundedJson(preparationPath));
+ if (mapped.preparationDigest !== digest || mapped.imageId !== image) throw Error("Persisted training preparation identity mismatch");
+ await verifyCanonicalPins(inputs, options.context.sources, staged);
+ } else {
+ let owned = false;
+ try {
+ await mkdir(staging, { mode: 0o700 }); owned = true;
+ try { await mkdir(output, { mode: 0o700 }); }
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST" || !(await lstat(output)).isDirectory() || await realpath(output) !== output) throw error; }
+ staged = await Promise.all(inputs.map(input => stageInput(input, path.join(staging, input.id))));
+ await verifyCanonicalPins(inputs, options.context.sources, staged);
+ image = imagePlan ? (await buildTrainingImage(imagePlan, { parent, dockerContext: config.dockerContext, process: options.process,
+ timeoutMs: options.timeoutMs, signal: options.signal, streams: options.streams })).imageId : "ref" in config.image ? config.image.ref : "";
+ if (!image.startsWith("sha256:")) {
+ const inspected = await execute(["--context", config.dockerContext, "image", "inspect", image, "--format", "{{.Id}}"]);
+ if (inspected.code !== 0) throw Error("Training image is unavailable"); image = inspected.stdout.trim();
+ }
+ const mapped: TrainingMappedPreparation = { version: "spawnfile.training-preparation.v1", preparationDigest: digest, imageId: image,
+ bindings: inputs.map(input => ({ inputId: input.id, destination: input.destination })), outputRoot: "/run/training/output",
+ packagePaths: { spawnfile: "/opt/training/spawnfile/dist/cli/index.js", paideia: "/opt/training/paideia", bridge: "/opt/training/paideia/bridges/dspy",
+ nativeWorker: "/opt/training/paideia/dist/src/adapters/daimon-native", integration: "/opt/training/integration", bootstrap: "/opt/training/bootstrap" }, integration: config.integration };
+ const launch = trainingContainerConfigSchema.parse({ version: "spawnfile.training-container.v1", dockerContext: config.dockerContext,
+ inputs: inputs.map((input, index) => ({ source: staged[index], destination: input.destination })), output: { source: output, destination: "/run/training/output" }, auth });
+ await writeFile(configPath, JSON.stringify(launch), { flag: "wx", mode: 0o600 });
+ await writeFile(preparationPath, JSON.stringify(parseTrainingMappedPreparation(mapped)), { flag: "wx", mode: 0o400 });
+ const snapshots = await Promise.all(inputs.map(async (input, index) => input.git ? hashJson(fileIdentity(await sealTree(staged[index]!, "input", { ignoreGit: true }))) : null));
+ await writeFile(statePath, JSON.stringify({ digest, image, staged, snapshots }), { flag: "wx", mode: 0o600 });
+ } catch (error) { if (owned) await rm(staging, { recursive: true, force: true }); throw error; }
+ }
+ const inspected = await execute(["--context", config.dockerContext, "image", "inspect", image, "--format", "{{.Id}}"]);
+ if (inspected.code !== 0 || inspected.stdout.trim() !== image) throw Error("Saved training image is missing or changed");
+ const map = (file: string): string => {
+ const absolute = path.resolve(file);
+ const index = roots.findIndex(source => within(source, absolute));
+ return index < 0 ? absolute : path.join(staged[index]!, path.relative(roots[index]!, absolute));
+ };
+ const context: TrainingContext = { ...options.context,
+ project: { ...options.context.project, root: map(options.context.project.root), manifest: map(options.context.project.manifest) },
+ agent: { ...options.context.agent, source: map(options.context.agent.source) },
+ sources: options.context.sources.map(source => ({ ...source, sourcePath: map(source.sourcePath) })),
+ documents: options.context.documents.map(source => ({ ...source, sourcePath: map(source.sourcePath) })),
+ skills: options.context.skills.map(source => ({ ...source, sourcePath: map(source.sourcePath) })) };
+ const args = [...options.args];
+ for (let index = 0; index < args.length; index++) {
+ if (["--train", "--test", "--cost-config"].includes(args[index]!)) args[++index] = map(args[index]!);
+ else if (args[index] === "--resource") { const value = args[++index]!, split = value.indexOf("="); args[index] = `${value.slice(0, split)}=${map(value.slice(split + 1))}`; }
+ else if (args[index] === "--out" && path.resolve(args[++index]!) !== output) throw Error("Training --out must match configured output");
+ }
+ return { digest, image, configPath, preparationPath, context, args };
+}
diff --git a/tsconfig.json b/tsconfig.json
index 4cdd75d..dc49e39 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -24,6 +24,7 @@
"src/deployment/native/build.ts",
"src/deployment/native/copyArtifacts.ts",
"src/evidenceExportHelper/copyAssets.ts",
- "src/runtime/copyScaffoldAssets.ts"
+ "src/runtime/copyScaffoldAssets.ts",
+ "src/compiler/training/preparation/copyAssets.ts"
]
}