Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions runtime-images/training/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,21 @@ The recipe is opt-in during incubation; no published runtime is implied.

## Layer order and modes

Layers run from least to most frequently changing: Python parent copies, the
baked `2000`/`2100`/`2200..2207` Daimon identities (context-independent, so first),
Layers run from least to most frequently changing: Python parent copies,
Bookworm's pinned `libfaketime=0.9.10-2.1`, the baked `2000`/`2100`/`2200..2207` Daimon identities (context-independent, so first),
locked Claude/Paideia/Spawnfile/compiler dependencies, the lock-keyed DSPy venv,
one layout RUN, bridge source plus its editable install, then the entrypoints and
distributions. Nothing RUNs after the late COPY layers, so a changed distribution
rebuilds its own COPY layer, every later COPY layer and the label, but no RUN
layer (no npm, pip or recursive chmod work).

Both parents must use Debian Bookworm. The early package layer resolves the
multiarch distro library with `dpkg-query` and creates the root-owned fixed
symlink `/usr/local/lib/daimon/libfaketime.so.1`. Never set image-wide
`LD_PRELOAD`: brokered Grok supplies it only through the clocked shell policy;
direct Daimon tool children receive it from the trial caller. The mode verifier
includes the symlink and resolved distro file on both architectures.

The image copies no Grok binary: `spawnfile.training-container.v4` runs the
native parent's pinned `/usr/local/bin/grok` through the broker, and a second
copy could only ever be an unattested build. The fixed uid/gid identities are
Expand Down
11 changes: 11 additions & 0 deletions runtime-images/training/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@ COPY --from=python /usr/lib/*-linux-gnu/libsqlite3.so.0* /opt/python/lib/
# 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

# Bookworm's distro library, shared by broker shell tools and direct clocked
# Daimon children. Resolve the multiarch path at build time; never preload the image.
RUN . /etc/os-release && test "$VERSION_CODENAME" = bookworm \
&& apt-get update && apt-get install --yes --no-install-recommends libfaketime=0.9.10-2.1 \
&& library="$(dpkg-query -L libfaketime | sed -n '\|/faketime/libfaketime\.so\.1$|p')" \
&& test -f "$library" \
&& install -d -o root -g root -m 0755 /usr/local/lib/daimon \
&& ln -s "$library" /usr/local/lib/daimon/libfaketime.so.1 \
&& chown -h root:root /usr/local/lib/daimon/libfaketime.so.1 \
&& rm -rf /var/lib/apt/lists/*

# The broker-capable container runs on a read-only root, so the fixed Daimon
# identities are baked here instead of being created by the entrypoint the way
# the production organization image creates them. It depends on nothing from the
Expand Down
7 changes: 6 additions & 1 deletion scripts/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,14 @@ tools live in `../archive/legacy-worktree-tools/` and are not active helpers.
- `vendor-daimon-grok-contract.ts` (run with `node --import tsx`) is the only
way Daimon's contract enters Spawnfile: it reads a Daimon checkout as data and
writes `src/runtime/daimon/contract-manifest.{json,sha256}` plus
`grokWorkerConfigBytes.ts` (Daimon's own worker `config.toml` renderer bytes
`grokWorkerConfigBytes.ts` (Daimon's unclocked and clocked worker `config.toml` renderer bytes
and sandbox-profile samples). `--check` fails on drift. It never builds or
writes inside Daimon.
Update `DAIMON_CONTRACT_MANIFEST_SHA256`, the manifest version and the mirrored
broker contract in `src/runtime/daimon/contractManifest.ts` by hand to match.
Preserve published registry digests/receipts until a real publication exists;
install and production-compile tests must express any required re-pin, never
relabel an old image as attesting the new manifest.
- The local builder pushes only to the fixed loopback development repository.
Its generated immutable manifest/receipt identity is ignored and never
edits `runtimes.yaml`. Clean-source builds select the native Docker
Expand Down
11 changes: 11 additions & 0 deletions scripts/daimon-publish-inputs.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import test from "node:test";
import { fileURLToPath } from "node:url";

import {
createPublishedDaimonCapabilityReceipt,
Expand All @@ -25,6 +27,15 @@ test("checked-in publish inputs pin a full Daimon commit, both architectures, an
assert.notEqual(resolvePublishedTag(inputs), "latest");
});

test("Daimon Dockerfile manifest literals accept the vendored contract", () => {
const dockerfile = readFileSync(new URL("../runtime-images/daimon/Dockerfile", import.meta.url), "utf8");
const validators = [...dockerfile.matchAll(/node -e '([^'\n]+)' \$\{RUNTIME_ROOT\}\/contract-manifest\.json/gu)];
assert.equal(validators.length, 1, "expected the Dockerfile's manifest validator");
const manifestPath = fileURLToPath(new URL("../src/runtime/daimon/contract-manifest.json", import.meta.url));
// Run the actual image-build guard: its version and every other literal must match the vendored bytes.
execFileSync(process.execPath, ["--input-type=commonjs", "-e", validators[0]![1]!, manifestPath], { timeout: 5_000 });
});

test("publish inputs reject floating or credential-bearing pins", () => {
type Mutable = {
agy: { platforms: Record<string, { url: string } | undefined> };
Expand Down
30 changes: 28 additions & 2 deletions scripts/training-image-modes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { assertControlRecipe, assertNewRecipe, CONTROL_CLOSURE, deriveControlRec

const row = (entry: string, mode = "755", type = "d", target = "") => `${entry}\t${mode}\troot\troot\t${type}\t${target}`;
const base = [row("/opt/training"), row("/opt/training/paideia/bridges/dspy/.venv"), row("/opt/training/bin/grok", "555", "f"),
row("/opt/training/bin/claude", "777", "l", "/opt/training/claude/node_modules/.bin/claude")];
row("/opt/training/bin/claude", "777", "l", "/opt/training/claude/node_modules/.bin/claude"),
row("/usr/local/lib/daimon"), row("/usr/local/lib/daimon/libfaketime.so.1", "777", "l", "/usr/lib/x86_64-linux-gnu/faketime/libfaketime.so.1"),
row("/usr/lib/x86_64-linux-gnu/faketime/libfaketime.so.1", "644", "f")];

test("identical listings in any order compare equal", () => {
assert.equal(identicalModes(diffModeListings(base.join("\n"), [...base].reverse().join("\n") + "\n")), true);
Expand All @@ -23,7 +25,7 @@ test("mode, owner, type and link target drift are each reported", () => {
});

test("entries present on only one side are reported", () => {
const diff = diffModeListings([...base, row("/opt/training/extra", "644", "f")].join("\n"), base.slice(0, 3).join("\n"));
const diff = diffModeListings([...base, row("/opt/training/extra", "644", "f")].join("\n"), base.filter((_entry, index) => index !== 3).join("\n"));
assert.deepEqual(diff.onlyNew, ["/opt/training/bin/claude", "/opt/training/extra"]);
assert.deepEqual(diff.onlyControl, []);
});
Expand Down Expand Up @@ -63,3 +65,27 @@ test("derives a control recipe that reapplies the whole-tree closure this recipe
assert.ok(control.lastIndexOf(CONTROL_CLOSURE) > control.lastIndexOf("\nCOPY "));
assert.throws(() => deriveControlRecipe(control), /no change-only a\+rX closure/u);
});

test("requires a real, root-owned, non-writable distro clock library on either architecture", () => {
const listing = base.join("\n");
for (const arch of ["x86_64", "aarch64"]) {
const selected = listing.replaceAll("x86_64", arch);
assert.equal(identicalModes(diffModeListings(selected, selected)), true);
}
assert.throws(() => diffModeListings(base.slice(0, -1).join("\n"), listing), /missing task clock library/u);
assert.throws(() => diffModeListings(listing.replace("\t644\troot", "\t666\troot"), listing), /unsafe.*task clock library/u);
assert.throws(() => diffModeListings(listing.replace("\t777\troot\troot\tl\t/usr/lib", "\t777\tworker\troot\tl\t/usr/lib"), listing), /unsafe task clock symlink/u);
});

test("installs the pinned Bookworm clock library and fixed symlink before context-dependent layers", async () => {
const recipe = await readFile(new URL("../runtime-images/training/Dockerfile", import.meta.url), "utf8");
assert.match(recipe, /test "\$VERSION_CODENAME" = bookworm/u);
assert.match(recipe, /apt-get install --yes --no-install-recommends libfaketime=0\.9\.10-2\.1/u);
assert.match(recipe, /dpkg-query -L libfaketime/u);
assert.match(recipe, /test -f "\$library"/u);
assert.match(recipe, /install -d -o root -g root -m 0755 \/usr\/local\/lib\/daimon/u);
assert.match(recipe, /ln -s "\$library" \/usr\/local\/lib\/daimon\/libfaketime\.so\.1/u);
assert.match(recipe, /chown -h root:root \/usr\/local\/lib\/daimon\/libfaketime\.so\.1/u);
assert.ok(recipe.indexOf("libfaketime=0.9.10-2.1") < recipe.indexOf("COPY claude/"));
assert.doesNotMatch(recipe, /^ENV (?:LD_PRELOAD|FAKETIME)/mu);
});
15 changes: 14 additions & 1 deletion scripts/training-image-modes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@

/** One `find -printf` record per entry: path, octal mode, owner, group, type, link target. */
export const MODE_LISTING_FORMAT = "%p\\t%m\\t%u\\t%g\\t%y\\t%l\\n";
export const MODE_ROOTS = ["/opt/training", "/opt/training/paideia/bridges/dspy/.venv"] as const;
export const TASK_CLOCK_LIBRARY = "/usr/local/lib/daimon/libfaketime.so.1";
export const MODE_ROOTS = ["/opt/training", "/opt/training/paideia/bridges/dspy/.venv", "/usr/local/lib/daimon"] as const;
/**
* `find` arguments. Only non-nested roots: `find a a/b` walks `a/b` twice and every
* entry beneath it would arrive duplicated, which `parse` rejects. The venv stays a
* required entry above, so its absence is still a failure.
*/
export const LISTING_ROOTS = MODE_ROOTS.filter((root, _index, all) =>
!all.some(other => other !== root && root.startsWith(other + "/")));
/** Include the resolved distro library too; finding the symlink alone cannot prove its target exists. */
export const MODE_LISTING_SCRIPT = `library="$(readlink -e ${TASK_CLOCK_LIBRARY})"; test -f "$library"; find ${LISTING_ROOTS.join(" ")} "$library" -printf '${MODE_LISTING_FORMAT}'`;
/** The former recipe's whole-tree closure; a control recipe without it is not a control. */
export const CONTROL_CLOSURE = "chmod -R a+rX /opt/training";

Expand Down Expand Up @@ -62,6 +65,16 @@ function parse(listing: string, label: string): Map<string, string> {
entries.set(entry, fields.join("\t"));
}
if (!MODE_ROOTS.every(root => entries.has(root))) throw Error(`${label} listing is missing a required root (${MODE_ROOTS.join(", ")})`);
const link = entries.get(TASK_CLOCK_LIBRARY)?.split("\t");
if (!link || link[0] !== "777" || link[1] !== "root" || link[2] !== "root" || link[3] !== "l"
|| !/^\/usr\/lib\/(x86_64|aarch64)-linux-gnu\/faketime\/libfaketime\.so\.1$/u.test(link[4]!)) {
throw Error(`${label} listing has an unsafe task clock symlink`);
}
const library = entries.get(link[4]!)?.split("\t");
if (!library || library[1] !== "root" || library[2] !== "root" || library[3] !== "f"
|| (Number.parseInt(library[0]!, 8) & 0o022) !== 0 || (Number.parseInt(library[0]!, 8) & 0o444) !== 0o444) {
throw Error(`${label} listing has an unsafe or missing task clock library`);
}
return entries;
}

Expand Down
4 changes: 2 additions & 2 deletions scripts/verify-training-image-modes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { mkdtemp, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promi
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { assertControlRecipe, assertNewRecipe, deriveControlRecipe, diffModeListings, identicalModes, LISTING_ROOTS, MODE_LISTING_FORMAT } from "./training-image-modes.ts";
import { assertControlRecipe, assertNewRecipe, deriveControlRecipe, diffModeListings, identicalModes, MODE_LISTING_SCRIPT } from "./training-image-modes.ts";

type ImageModule = typeof import("../src/compiler/training/preparation/image.js");
type FilesModule = typeof import("../src/compiler/training/preparation/files.js");
Expand Down Expand Up @@ -76,7 +76,7 @@ async function main(): Promise<void> {
docker(dockerContext, ["build", ...buildArgs, "--tag", tags.next, await stage("new", plan.dockerfile, true)]);
docker(dockerContext, ["build", ...buildArgs, "--tag", tags.control, await stage("control", controlRecipe, false)]);
const listing = (tag: string) => docker(dockerContext, ["run", "--rm", "--platform", build.platform, "--user", "0:0", "--network", "none",
"--entrypoint", "find", tag, ...LISTING_ROOTS, "-printf", MODE_LISTING_FORMAT], true);
"--entrypoint", "/bin/sh", tag, "-ceu", MODE_LISTING_SCRIPT], true);
const diff = diffModeListings(listing(tags.next), listing(tags.control));
if (!identicalModes(diff)) {
for (const entry of diff.onlyNew.slice(0, 50)) console.error(`only in new: ${entry}`);
Expand Down
29 changes: 29 additions & 0 deletions specs/CONTAINERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,35 @@ Daimon contract enters Spawnfile: it reads a Daimon checkout
(`SPAWNFILE_DAIMON_SOURCE_DIR`) and writes the canonical contract manifest and
digest plus Daimon's worker `config.toml` renderer bytes.

Manifest v4 adds the separate clocked worker variant, engine-broker protocol v3
and projection v3. It also declares `wakeMoments` and `agents[].recording`:
with an image attesting this manifest, declared training recording passes the
recording capability gate. Recording remains opt-in.

The published `0.2.0-8349559` pin still attests manifest
`sha256:f7bdd283c420cb6685d32fb556bfb1dcf48f756b3a5ca5ae61e96950152a5cc0`,
not the newly vendored manifest. Until a real re-pin, **all production Daimon
container compiles refuse** the exact-manifest gate, including unclocked
organizations. Schedule capability is reported as degraded and schedule/attention
target creation refuses under the old pin. The selected image is never granted
authority by version alone. A matching attested local development identity
remains available; no published digest or receipt is fabricated here.

`src/runtime/install.test.ts` preserves the old published identity and asserts
the manifest mismatch. `src/runtime/container.test.ts`,
`src/compiler/publicDaimonHost.test.ts`, `src/compiler/recordingCapability.test.ts`,
and the Daimon adapter, schedule-authority and attention tests now explicitly
express **requires a re-pin**. The published
authority tests still prove that an exact matching published receipt is accepted.

Release order: merge Daimon; update `publish-inputs.json` to the exact merged
commit that emits the vendored manifest; publish the immutable image through
the `runtime-images` workflow's `workflow_dispatch`; then review and pin its
index digest, manifest digest and per-architecture capability receipts in
`runtimes.yaml`. Restore the production acceptance expectations only with those
real receipts. Build training images from that runtime (or an explicitly
attested matching local artifact), then run the Linux clock/sandbox checks.

Daimon organizations containing a Grok agent run with
`--security-opt=seccomp=<pinned default-plus-userns profile>` and
`--security-opt=apparmor=unconfined`; the profile ships with Spawnfile and is
Expand Down
13 changes: 13 additions & 0 deletions specs/TRAINING.md
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,7 @@ Each process MUST have exactly one offset owner. These pure helpers replace
| `harnessTaskClockEnvironment` | Top-level agent harness (Daimon), with an unshifted process clock | Only `NOOPOLIS_TASK_CLOCK`: compact JSON `{"version":"noopolis.task-clock.v1","origin":"<ISO Z>","anchorEpochMs":<int>}` |
| `processClockEnvironment` | Child/tool process under libfaketime | Only `FAKETIME`: signed relative seconds `(originEpochMs - anchorEpochMs) / 1000`, and `FAKETIME_DONT_FAKE_MONOTONIC=1` |
| `clockAwareEnvironment` | Clock-aware child (such as Mneme) without libfaketime | Only `MNEME_CLOCK_ORIGIN`: normalized origin, and `MNEME_CLOCK_ANCHOR_MS`: decimal anchor milliseconds |
| Daimon broker `start_turn.processClock` | Brokered Grok in a clocked training slot | Signed relative seconds over control protocol v4; the broker atomically publishes the offset to `/run/daimon-engine-broker/task-clock/slot-<n>/faketime` before launch. The pinned clocked shell policy supplies `LD_PRELOAD=/usr/local/lib/daimon/libfaketime.so.1`, `FAKETIME_TIMESTAMP_FILE`, `FAKETIME_DONT_FAKE_MONOTONIC=1` and `LC_ALL=C`. |

Daimon consumes the top-level contract and derives each child's environment itself.
Trial preparation supplies the installed libfaketime path through `LD_PRELOAD` for
Expand All @@ -725,6 +726,18 @@ instead omit libfaketime from `LD_PRELOAD` and omit `FAKETIME` and
`NOOPOLIS_TASK_CLOCK`. Applying both offsets would shift time twice. The top-level
harness MUST NOT itself run under libfaketime or receive the Mneme offset variables.

The training image installs the fixed preload path above for both direct Daimon
tool children and brokered Grok shells. Brokered training uses the manifest's
separate `clockedConfigSha256` matrix, never modified production bytes. A clocked
training container MUST isolate each registration's clock directory; turns in
different leased slots may overlap.
An unclocked turn in that slot resets the file to `+0` before launch. The clock
directory is broker-owned and readable through the worker's read-only `/run`
directory mount; a file-only bind would retain a stale inode after publication.
The launcher, broker, Grok process, authentication and TLS remain on real time.
Daimon also rewrites Grok's provider-injected date for clocked turns; this is
separate from shell clock coverage. Production's unclocked config is unchanged.

Relative `FAKETIME` MUST include a sign, use seconds without a suffix, and preserve
milliseconds (three fractional digits when nonzero; no fraction for integral
seconds). Integer-second rounding and the `@` start-at form are forbidden.
Expand Down
37 changes: 37 additions & 0 deletions specs/TRAINING_CONTAINERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,43 @@ one Grok credential in the container.

### Privilege table

The v4 slots select Daimon's manifest-v5 clocked worker configs. The image
requires Debian Bookworm parents and installs `libfaketime=0.9.10-2.1` before any
context-dependent layer. Its root-owned architecture-neutral symlink
`/usr/local/lib/daimon/libfaketime.so.1` points to the distro multiarch library;
the library, link and ancestors are not worker-writable. The image never sets
ambient `LD_PRELOAD`. Trial callers supply this same path to direct Daimon
clocked tool children; the brokered worker's pinned shell policy supplies it
only to native shell children.

Provisioning creates `/run/daimon-engine-broker/task-clock` as `0:0 0755`,
each `slot-<n>` directory as `2100:2100 0755`, and its `faketime` file as `2100:2100 0644`, initially `+0\n`. The broker
publishes the turn's signed offset atomically before launch, or `+0` for an
unclocked turn. Every registration has its own clock directory; leased slots may overlap.
The pinned `daimon-strict` profile inherits read-only directory mounts for
`/run` and `/usr`; the entire clock directory and resolved distro library stay
visible, with no file-only bind and no deny mask over them. The three control,
backend and launcher sockets are individually denied. Root creates broker-owned
`0600` Unix socket nodes before the shared deny-path assertions. They remain
through Daimon's startup attestation, which precedes backend listen; each
listener replaces its own node and readiness requires a changed socket identity.
Bubblewrap materializes deny targets as the worker UID, and Daimon's backend
refuses a regular file where its socket belongs.

Slot preflight checks the actual installed config digest, clock owners/modes,
library identity, worker readability and denied writes. Its unchanged
`grok-slot-preflight.v2` receipt binds projection v3, including
`workerConfigVariant: "clocked"`, `workerConfigSha256` and `taskClock`.
Paideia must request that same variant when recomputing the projection and use
control protocol v4; an old projection or protocol is refused. Shared startup
reclaims the clock subtree before clearing the broker root and recreates `+0`.

Before release, a Linux slot must prove a nonzero offset (including fractional
seconds) under the actual Grok sandbox, denied clock writes/unlinks/replacement,
fresh reads after broker rename, and reset to real time with `+0`. Verify the
broker/native environment and monotonic deadlines remain real. These checks
need Docker/Linux and do not follow from rendering or mode tests alone.

| Process | uid | Capability bounding set | Where it comes from |
| --- | --- | --- | --- |
| container | 0 | `CHOWN,SETUID,SETGID,SETPCAP,KILL,DAC_READ_SEARCH` | `docker create` (`no-new-privileges`, read-only root, pids 2048) |
Expand Down
Loading
Loading