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" ] }