diff --git a/runtime-images/training/AGENTS.md b/runtime-images/training/AGENTS.md index 36f39fd8..ac58968b 100644 --- a/runtime-images/training/AGENTS.md +++ b/runtime-images/training/AGENTS.md @@ -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 diff --git a/runtime-images/training/Dockerfile b/runtime-images/training/Dockerfile index ad526d71..785368bb 100644 --- a/runtime-images/training/Dockerfile +++ b/runtime-images/training/Dockerfile @@ -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 diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 1140bad4..7c9e24d6 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -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 diff --git a/scripts/daimon-publish-inputs.test.ts b/scripts/daimon-publish-inputs.test.ts index 7c22b49d..060b86b5 100644 --- a/scripts/daimon-publish-inputs.test.ts +++ b/scripts/daimon-publish-inputs.test.ts @@ -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, @@ -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 }; diff --git a/scripts/training-image-modes.test.ts b/scripts/training-image-modes.test.ts index deea91a9..8c90b25f 100644 --- a/scripts/training-image-modes.test.ts +++ b/scripts/training-image-modes.test.ts @@ -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); @@ -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, []); }); @@ -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); +}); diff --git a/scripts/training-image-modes.ts b/scripts/training-image-modes.ts index d3a0b191..d69740a5 100644 --- a/scripts/training-image-modes.ts +++ b/scripts/training-image-modes.ts @@ -2,7 +2,8 @@ /** 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 @@ -10,6 +11,8 @@ export const MODE_ROOTS = ["/opt/training", "/opt/training/paideia/bridges/dspy/ */ 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"; @@ -62,6 +65,16 @@ function parse(listing: string, label: string): Map { 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; } diff --git a/scripts/verify-training-image-modes.ts b/scripts/verify-training-image-modes.ts index a4b53930..ae2cf113 100644 --- a/scripts/verify-training-image-modes.ts +++ b/scripts/verify-training-image-modes.ts @@ -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"); @@ -76,7 +76,7 @@ async function main(): Promise { 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}`); diff --git a/specs/CONTAINERS.md b/specs/CONTAINERS.md index 34951f5b..056c0712 100644 --- a/specs/CONTAINERS.md +++ b/specs/CONTAINERS.md @@ -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=` and `--security-opt=apparmor=unconfined`; the profile ships with Spawnfile and is diff --git a/specs/TRAINING.md b/specs/TRAINING.md index 687b939a..f5f6a37b 100644 --- a/specs/TRAINING.md +++ b/specs/TRAINING.md @@ -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":"","anchorEpochMs":}` | | `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-/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 @@ -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. diff --git a/specs/TRAINING_CONTAINERS.md b/specs/TRAINING_CONTAINERS.md index 80485932..31be2e69 100644 --- a/specs/TRAINING_CONTAINERS.md +++ b/specs/TRAINING_CONTAINERS.md @@ -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-` 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) | diff --git a/src/compiler/AGENTS.md b/src/compiler/AGENTS.md index 2c8ff610..0353c900 100644 --- a/src/compiler/AGENTS.md +++ b/src/compiler/AGENTS.md @@ -44,6 +44,7 @@ src/compiler/ ├── containerDaimonBrokerRender.ts # Fixed Daimon broker identities, registrations binary, credential realm, and root-launch provisioning ├── containerDaimonGrokWorkerRender.ts # Brokered Grok registrations: pinned worker config, sandbox deny list, service.json v2 ├── containerDaimonGrokWorkerProvisioning.ts # Root program lines for the attested worker GROK_HOME layout and canonical-path checks +├── containerDaimonGrokTaskClock.ts # Slot-specific shared root clock and socket provisioning ├── containerArtifactsPlans.ts # Environment inventory and runtime target-plan orchestration ├── containerTargetPlanResolution.ts # Per-target paths, packages, auth, secrets, and exposure resolution ├── teamRoster.ts # Context-scoped team roster generation and diagnostics diff --git a/src/compiler/containerDaimonGrokTaskClock.ts b/src/compiler/containerDaimonGrokTaskClock.ts new file mode 100644 index 00000000..a0229225 --- /dev/null +++ b/src/compiler/containerDaimonGrokTaskClock.ts @@ -0,0 +1,32 @@ +import { DAIMON_GROK_ENGINE_BROKER } from "../runtime/daimon/contractManifest.js"; +import type { DaimonGrokRegistration } from "./containerDaimonGrokWorkerRender.js"; + +const broker = DAIMON_GROK_ENGINE_BROKER; +const clock = broker.worker.taskClock; + +/** Exit without closing/unlinking the listener: the next process needs a socket inode, not a live peer. */ +export const GROK_SOCKET_PLACEHOLDER_PROGRAM = [ + "const fs = require('node:fs'), net = require('node:net');", + "const [file, uid, gid] = process.argv.slice(1);", + "const server = net.createServer(); server.on('error', (error) => { console.error(error.message); process.exit(1); });", + "server.listen(file, () => { fs.chmodSync(file, 0o600); fs.chownSync(file, Number(uid), Number(gid)); process.exit(0); });" +].join("\n"); + +/** Lines inside the shared root program, after its canonical-path and ownership helpers. */ +export const renderDaimonGrokTaskClockProvisioning = (registrations: readonly DaimonGrokRegistration[]): string[] => { + if (!registrations.some((entry) => entry.configVariant === "clocked")) return []; + return [ + `const taskClock = ${JSON.stringify(clock)};`, + `const clocks = ${JSON.stringify(registrations.filter(entry => entry.configVariant === "clocked").map(entry => ({ directoryPath: clock.slotDirectoryPattern.replace("{slot}", String(entry.slot)), timestampFilePath: clock.slotFilePattern.replace("{slot}", String(entry.slot)) })))};`, + // All ancestors must stay searchable and non-writable by the worker, including the control root. + "for (const entry of grokWorkers) { for (let at = require('node:path').dirname(taskClock.rootDirectoryPath);; at = require('node:path').dirname(at)) { const info = assertCanonical(at, 'task clock ancestor'); const access = info.uid === entry.uid ? (info.mode >> 6) & 7 : info.gid === entry.uid ? (info.mode >> 3) & 7 : info.mode & 7; if (!info.isDirectory() || !(access & 1) || (access & 2)) throw new Error(`unsafe task clock ancestor: ${at}`); if (at === '/') break; } }", + "ensureDirectory(taskClock.rootDirectoryPath, 0, 0, taskClock.directoryMode); assertCanonical(taskClock.rootDirectoryPath, 'task clock root');", + "for (const current of clocks) { ensureDirectory(current.directoryPath, 0, 0, taskClock.directoryMode); assertCanonical(current.directoryPath, 'task clock');", + "ensureExactFile(current.timestampFilePath, '+0\\n', taskClock.fileMode); fs.chownSync(current.timestampFilePath, taskClock.ownerUid, taskClock.ownerUid);", + "withMode(current.directoryPath, taskClock.directoryMode, taskClock.ownerUid, taskClock.ownerUid); }", + // Startup attestation runs BEFORE the backend listens. All deny targets must already exist, + // and the backend only replaces a socket owned by its uid. Listeners unlink these real socket + // nodes in turn; readiness must prove replacement, never certify a placeholder as a listener. + `for (const socket of ${JSON.stringify([broker.controlSocketPath, broker.backendSocketPath, broker.launcherSocketPath])}) { require('node:child_process').execFileSync(process.execPath, ['-e', ${JSON.stringify(GROK_SOCKET_PLACEHOLDER_PROGRAM)}, socket, '${clock.ownerUid}', '${clock.ownerUid}'], { timeout: 5000, stdio: 'pipe' }); const info = fs.lstatSync(socket); if (!info.isSocket() || info.uid !== ${clock.ownerUid} || info.gid !== ${clock.ownerUid} || (info.mode & 0o7777) !== 0o600) throw new Error('unsafe broker socket placeholder'); }` + ]; +}; diff --git a/src/compiler/containerDaimonGrokWorkerProvisioning.test.ts b/src/compiler/containerDaimonGrokWorkerProvisioning.test.ts index 5757097e..a5aefddc 100644 --- a/src/compiler/containerDaimonGrokWorkerProvisioning.test.ts +++ b/src/compiler/containerDaimonGrokWorkerProvisioning.test.ts @@ -9,11 +9,15 @@ import { DAIMON_GROK_WORKER_CONFIG_BYTES } from "../runtime/daimon/grokWorkerCon import type { RuntimeTargetPlan } from "./containerArtifactsTypes.js"; import { renderDaimonGrokHostPreflight, renderDaimonGrokWorkerProvisioning } from "./containerDaimonGrokWorkerProvisioning.js"; import { resolveDaimonGrokRegistrations, type DaimonGrokRegistration } from "./containerDaimonGrokWorkerRender.js"; +import { trainingSlotDirectories } from "./training/broker/provisioning.js"; +import { resolveTrainingGrokRegistration } from "./training/broker/registration.js"; +import { TRAINING_BROKER_SOCKET_PATHS, TRAINING_OPTIONAL_DENY_DIRECTORIES } from "./training/broker/paths.js"; +import { DAIMON_GROK_CLOCKED_WORKER_CONFIG_BYTES } from "../runtime/daimon/grokWorkerConfigBytes.js"; const INSTANCE = "/var/lib/spawnfile/instances/daimon/daimon-organization"; const BROKER = 2100; -type Node = { content: string; gid: number; kind: "dir" | "file" | "link"; mode: number; target?: string; uid: number }; +type Node = { content: string; gid: number; kind: "dir" | "file" | "link" | "socket"; mode: number; target?: string; uid: number }; /** * Just enough of `node:fs` to run the rendered root program without root — including the one @@ -39,7 +43,7 @@ const memoryFs = (seed: Record>) => { const stat = (target: string) => { const node = get(target); const type = node.kind === "dir" ? 0o040000 : node.kind === "file" ? 0o100000 : 0o120000; - return { gid: node.gid, isDirectory: () => node.kind === "dir", isFile: () => node.kind === "file", isSymbolicLink: () => node.kind === "link", mode: type | node.mode, nlink: 1, uid: node.uid }; + return { gid: node.gid, isDirectory: () => node.kind === "dir", isFile: () => node.kind === "file", isSocket: () => node.kind === "socket", isSymbolicLink: () => node.kind === "link", mode: type | node.mode, nlink: 1, uid: node.uid }; }; // uid 0 without CAP_DAC_OVERRIDE: only the owner/group/other write bits that actually apply to it. const rootMayWrite = (node: Node) => (node.uid === 0 && (node.mode & 0o200) !== 0) @@ -64,7 +68,7 @@ const memoryFs = (seed: Record>) => { if (nodes.has(target)) { if (options.recursive) return; throw Object.assign(new Error("EEXIST"), { code: "EEXIST" }); } if (!options.recursive) get(path.posix.dirname(target)); assertCreatableIn(target); - put(target, { kind: "dir", mode: options.mode ?? 0o755 }); + put(target, { kind: "dir", mode: (options.mode ?? 0o755) & ~process.umask() }); }, readFileSync: (target: string) => get(target).content, realpathSync: (target: string) => { @@ -80,7 +84,7 @@ const memoryFs = (seed: Record>) => { writeFileSync: (target: string, content: string, options: { flag?: string; mode?: number } = {}) => { if (options.flag === "wx" && nodes.has(target)) throw Object.assign(new Error("EEXIST"), { code: "EEXIST" }); if (!nodes.has(target)) assertCreatableIn(target); - put(target, { content: String(content), kind: "file", mode: options.mode ?? 0o644 }); + put(target, { content: String(content), kind: "file", mode: (options.mode ?? 0o644) & ~process.umask() }); } }; return { fs, nodes }; @@ -105,15 +109,61 @@ const run = (registrations: readonly DaimonGrokRegistration[], seed: Record undefined; const program = new Function("fs", "crypto", "secureWorkspace", "require", lines.join("\n")); - program(memory.fs, crypto, secureWorkspace, (name: string) => name === "node:path" ? path.posix : undefined); + program(memory.fs, crypto, secureWorkspace, (name: string) => name === "node:path" ? path.posix : name === "node:child_process" ? { + execFileSync: (_command: string, args: string[]) => { + const target = args[2]!; + memory.fs.writeFileSync(target, "", { flag: "wx", mode: 0o600 }); + Object.assign(memory.nodes.get(target)!, { kind: "socket", uid: Number(args[3]), gid: Number(args[4]) }); + } + } : undefined); return memory.nodes; }; describe("Grok worker home provisioning", () => { const registrations = resolveDaimonGrokRegistrations([plan({ "agent:a": "grok", "agent:b": "grok", "agent:c": "codex" })]); + it("executes clocked training provisioning with exact clock ownership and modes, including a restrictive umask", () => { + const entry = resolveTrainingGrokRegistration({ agentId: "agent:a", model: "grok-4.6", reasoningEffort: "low" }); + const clock = DAIMON_GROK_ENGINE_BROKER.worker.taskClock; + const seed = { ...seedFor([entry], [...TRAINING_BROKER_SOCKET_PATHS]), "/run/daimon-engine-broker": { gid: BROKER, mode: 0o731 } }; + const lines = renderDaimonGrokWorkerProvisioning([entry], {}, TRAINING_OPTIONAL_DENY_DIRECTORIES); + const previous = process.umask(0o077); + let nodes: Map; + try { nodes = run([entry], seed, lines); } finally { process.umask(previous); } + expect(nodes.get(`${entry.grokHome}/config.toml`)).toMatchObject({ + content: DAIMON_GROK_CLOCKED_WORKER_CONFIG_BYTES["grok-4.6"].low, uid: 0, gid: 0, mode: 0o444 + }); + expect(nodes.get(clock.directoryPath)).toMatchObject({ kind: "dir", uid: 2100, gid: 2100, mode: 0o755 }); + expect(nodes.get(clock.timestampFilePath)).toMatchObject({ kind: "file", content: "+0\n", uid: 2100, gid: 2100, mode: 0o644 }); + for (const socket of TRAINING_BROKER_SOCKET_PATHS) expect(nodes.get(socket)).toMatchObject({ kind: "socket", uid: 2100, gid: 2100, mode: 0o600 }); + expect(() => run([entry], { ...seed, "/run/daimon-engine-broker": { gid: BROKER, mode: 0o730 } }, lines)).toThrow(/task clock ancestor/u); + }); + + it("provisions four clocked registrations with each slot's own clock and digest", () => { + const entries = Array.from({ length: 4 }, (_, slot) => resolveTrainingGrokRegistration({ + agentId: "agent:a", model: "grok-4.6", reasoningEffort: "low", slot, slots: 4 + })); + const seed = { ...seedFor(entries, [...TRAINING_BROKER_SOCKET_PATHS]), + ...Object.fromEntries(entries.flatMap(entry => trainingSlotDirectories(entry.slot)).map(directory => [directory.path, + { kind: "dir" as const, uid: directory.uid, gid: directory.gid, mode: Number.parseInt(directory.mode, 8) }])), + "/run/daimon-engine-broker": { gid: BROKER, mode: 0o731 } }; + const lines = renderDaimonGrokWorkerProvisioning(entries, { version: "v3" }, TRAINING_OPTIONAL_DENY_DIRECTORIES); + const nodes = run(entries, seed, lines); + for (const entry of entries) { + const clock = DAIMON_GROK_ENGINE_BROKER.worker.taskClock; + const file = clock.slotFilePattern.replace("{slot}", String(entry.slot)); + const directory = clock.slotDirectoryPattern.replace("{slot}", String(entry.slot)); + expect(nodes.get(file)).toMatchObject({ kind: "file", uid: 2100, gid: 2100, mode: 0o644, content: "+0\n" }); + expect(nodes.get(directory)).toMatchObject({ kind: "dir", uid: 2100, gid: 2100, mode: 0o755 }); + expect(nodes.get(`${entry.grokHome}/config.toml`)!.content).toBe(entry.config); + expect(entry.config).toContain(file); + } + expect(new Set(entries.map(entry => entry.configSha256)).size).toBe(4); + }); + it("provisions Daimon's attested layout: sticky root:worker homes and root-owned read-only turn files", () => { const nodes = run(registrations, seedFor(registrations)); + expect(nodes.has(DAIMON_GROK_ENGINE_BROKER.worker.taskClock.directoryPath)).toBe(false); for (const entry of registrations) { expect(nodes.get(entry.home)).toMatchObject({ gid: BROKER, mode: 0o710, uid: entry.uid }); for (const directory of [entry.grokHome, `${entry.grokHome}/sessions`]) { diff --git a/src/compiler/containerDaimonGrokWorkerProvisioning.ts b/src/compiler/containerDaimonGrokWorkerProvisioning.ts index 4704aee7..1ced760b 100644 --- a/src/compiler/containerDaimonGrokWorkerProvisioning.ts +++ b/src/compiler/containerDaimonGrokWorkerProvisioning.ts @@ -1,3 +1,4 @@ +import { renderDaimonGrokTaskClockProvisioning } from "./containerDaimonGrokTaskClock.js"; import { DAIMON_GROK_ENGINE_BROKER } from "../runtime/daimon/contractManifest.js"; import { resolveDaimonGrokClockedWorkerConfig, resolveDaimonGrokWorkerConfig } from "../runtime/daimon/grokWorkerContract.js"; import { DAIMON_BROKER_UID, DAIMON_ORGANIZATION_UID } from "../runtime/daimon/runtimeIdentity.js"; @@ -86,6 +87,7 @@ export const renderDaimonGrokWorkerProvisioning = ( "const ensureDirectory = (target, uid, gid, mode) => { fs.mkdirSync(target, { recursive: true, mode: 0o700 }); const info = fs.lstatSync(target); if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('unsafe worker runtime directory'); fs.chownSync(target, 0, 0); fs.chmodSync(target, mode); fs.chownSync(target, uid, gid); };", "const ensureExactFile = (target, content, mode) => { let info; try { info = fs.lstatSync(target); } catch (error) { if (error.code !== 'ENOENT') throw error; fs.writeFileSync(target, content, { mode, flag: 'wx' }); info = fs.lstatSync(target); } if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1) throw new Error('unsafe worker runtime file'); if (fs.readFileSync(target, 'utf8') !== content) throw new Error(`worker runtime file identity mismatch: ${target}`); fs.chownSync(target, 0, 0); fs.chmodSync(target, mode); };", `const ensureEventsFile = (target, uid) => { let info; try { info = fs.lstatSync(target); } catch (error) { if (error.code !== 'ENOENT') throw error; fs.writeFileSync(target, '', { mode: 0o640, flag: 'wx' }); info = fs.lstatSync(target); } if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1 || (info.uid !== uid && info.uid !== 0) || ![0, uid, ${DAIMON_BROKER_UID}].includes(info.gid) || ![0o600, 0o640, 0o644].includes(info.mode & 0o777)) throw new Error('unsafe worker attestation events'); fs.chownSync(target, 0, 0); fs.chmodSync(target, 0o640); fs.chownSync(target, uid, ${DAIMON_BROKER_UID}); };`, + ...(scope === "slot" ? [] : renderDaimonGrokTaskClockProvisioning(registrations)), ...(scope === "slot" ? [] : [`fs.mkdirSync('${DAIMON_WORKER_ROOT}', { recursive: true, mode: 0o711 }); fs.chownSync('${DAIMON_WORKER_ROOT}', 0, 0); fs.chmodSync('${DAIMON_WORKER_ROOT}', 0o711); assertCanonical('${DAIMON_WORKER_ROOT}', 'worker root');`]), // Pass 1: every workspace and home exists, root-held, before any deny list is checked. `for (const entry of grokWorkers) { traversable(entry.workspace); secureWorkspace(entry.workspace, entry.uid); assertCanonical(entry.workspace, 'workspace'); ${scope === "container" ? "ensureDirectory(entry.home, 0, 0, 0o700);" : ""} ensureDirectory(entry.grokHome, 0, 0, 0o700); ensureDirectory(\`\${entry.grokHome}/sessions\`, 0, 0, 0o700); for (const target of [entry.home, entry.grokHome]) assertCanonical(target, 'home'); }`, diff --git a/src/compiler/training/broker/AGENTS.md b/src/compiler/training/broker/AGENTS.md index 055deef8..37716b45 100644 --- a/src/compiler/training/broker/AGENTS.md +++ b/src/compiler/training/broker/AGENTS.md @@ -25,6 +25,10 @@ container that runs a brokered Grok subject beside the evaluator. preflight publication after shared startup. Recycles serialize per slot; different slots overlap. Paideia must retain its exclusive slot lease throughout recycle. - `receipt.ts` writes `noopolis.daimon.grok-slot-preflight.v2`. +- `taskClock.ts` reclaims the clock subtree only during shared startup, + and checks installed config bytes, clock modes, library identity and worker + access before the receipt. `projection.ts` requires Daimon's projection v3 + with `workerConfigVariant: "clocked"` and the selected digest. - `seccompRoutes.ts` answers, from the pinned profile bytes alone, whether the worker uid may even attempt a namespace escape — the only honest way to say `seccomp` rather than `kernel` when a route is unavailable. @@ -32,6 +36,20 @@ container that runs a brokered Grok subject beside the evaluator. Local constraints: +- Training installs a slot-specific clocked config for every registration. + The shared root renderer creates `/run/daimon-engine-broker/task-clock` as + `0:0 0755`, each `slot-` directory as `2100:2100 0755`, and its `faketime` + file as `2100:2100 0644`, initially `+0\n`. Reclaim the subtree only before + shared startup; per-slot recycle resets clocks through the broker API; root lacks `CAP_DAC_OVERRIDE` and `CAP_FOWNER`. +- Keep the clock directory and distro preload library visible through the + pinned strict profile's read-only `/run` and `/usr` directory mounts. Never + bind only the timestamp file: broker publication replaces its inode. Mask + the three broker sockets individually, not their shared ancestor. Create + broker-owned `0600` Unix socket nodes for deny-placement checks. Keep them + through startup attestation (which runs before the backend listens); each + listener replaces its own node. Readiness requires that replacement, not + just a socket's existence. Daimon refuses a regular file at startup. + - Nothing per-trial may live on the Grok realm volume. The realm holds only `auth.json` and the broker credential journal; the turn store, worker home, workspace, wake-acceptance store, per-slot ledger and Grok session state are @@ -80,9 +98,9 @@ Local constraints: - Grok 1.0.34 materializes every `deny` target inside bubblewrap as the worker uid, so a deny entry it cannot create makes the whole profile fail. That is why `/run/paideia` is masked as a directory rather than file by file, and why - provisioning creates every non-bind deny target itself. One entry still fails - this way (`.runtime/grok-p5/EVIDENCE.md`): the wake-acceptance store, which is - Daimon's own protected path under a `2000:2000 0700` parent. + provisioning creates every non-bind deny target itself. The wake-acceptance + store is masked through its state-directory parent, whose ancestors remain + searchable; a file mask beneath that `2000:2000 0700` directory would fail. - **Nothing the launch mounts may sit inside a wipe target.** A mount point cannot be unlinked while it is mounted, so a recycle that must remove or empty a directory holding one aborts. The broker/relay `TMPDIR` lived at the diff --git a/src/compiler/training/broker/paths.parallel.test.ts b/src/compiler/training/broker/paths.parallel.test.ts index 2b4d7a31..1976e628 100644 --- a/src/compiler/training/broker/paths.parallel.test.ts +++ b/src/compiler/training/broker/paths.parallel.test.ts @@ -78,12 +78,12 @@ describe("four independent training slots", () => { expect(JSON.parse(serviceLine.slice("const service = ".length, -1)).maxRunRequests).toBe(17); expect(JSON.parse(serviceLine.slice("const service = ".length, -1)).runRequestStore).toBe(TRAINING_RUN_REQUEST_STORE); const start = script.indexOf("node <<'SPAWNFILE_DAIMON_BROKER_PROVISION'"); - const end = script.indexOf("\nSPAWNFILE_DAIMON_BROKER_PROVISION"); for (const socket of TRAINING_BROKER_SOCKET_PATHS) { - expect(script.indexOf(`touch '${socket}'`)).toBeGreaterThanOrEqual(0); - expect(script.indexOf(`touch '${socket}'`)).toBeLessThan(start); - expect(script.indexOf(`rm '${socket}'`)).toBeGreaterThan(end); + expect(script).toContain(JSON.stringify(socket)); + expect(script).not.toContain(`touch '${socket}'`); + expect(script).not.toContain(`rm '${socket}'`); } + expect(script).toContain('unsafe broker socket placeholder'); for (const p of trainingSlotDescriptors()) { expect(script.indexOf(`mkdir -p '${p.clockDirectory}'`)).toBeGreaterThanOrEqual(0); expect(script.indexOf(`mkdir -p '${p.clockDirectory}'`)).toBeLessThan(start); diff --git a/src/compiler/training/broker/processes.test.ts b/src/compiler/training/broker/processes.test.ts new file mode 100644 index 00000000..ea27cc27 --- /dev/null +++ b/src/compiler/training/broker/processes.test.ts @@ -0,0 +1,53 @@ +import { execFileSync } from "node:child_process"; +import { lstat, mkdtemp, rm, unlink } from "node:fs/promises"; +import { createServer } from "node:net"; +import { afterEach, describe, expect, it } from "vitest"; +import { GROK_SOCKET_PLACEHOLDER_PROGRAM } from "../../containerDaimonGrokTaskClock.js"; +import { isStartedBrokerSocket } from "./processes.js"; + +const roots: string[] = []; +afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); + +describe("broker startup with clocked socket deny targets", () => { + it.skipIf(process.platform !== "linux")("leaves a real, owned socket inode that a listener can replace after startup attestation (Linux)", async () => { + // Short paths: Unix sockets have a small platform-specific address bound. + const root = await mkdtemp("/tmp/spawnfile-socket-"); roots.push(root); + const file = `${root}/backend.sock`, uid = process.getuid!(), gid = process.getgid!(); + execFileSync(process.execPath, ["-e", GROK_SOCKET_PLACEHOLDER_PROGRAM, file, String(uid), String(gid)], { timeout: 5000 }); + const placeholder = await lstat(file); + expect(placeholder.isSocket()).toBe(true); + expect([placeholder.uid, placeholder.gid, placeholder.mode & 0o7777]).toEqual([uid, gid, 0o600]); + expect(isStartedBrokerSocket(placeholder, placeholder)).toBe(false); + // Daimon's backend refuses a regular file, accepts an owned socket, then unlinks and listens. + await unlink(file); + const server = createServer(); + await new Promise((resolve, reject) => { server.once("error", reject); server.listen(file, resolve); }); + try { expect(isStartedBrokerSocket(await lstat(file), placeholder)).toBe(true); } + finally { await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); } + }); + + it("creates socket placeholders with the backend's uid and modes before the child exits", () => { + const steps: string[] = []; + const socket = "/run/daimon-engine-broker/backend.sock"; + const fs = { chmodSync: (file: string, mode: number) => { expect(file).toBe(socket); steps.push(`mode ${mode.toString(8)}`); }, + chownSync: (file: string, uid: number, gid: number) => { expect(file).toBe(socket); steps.push(`owner ${uid}:${gid}`); } }; + const server = { on: () => undefined, listen: (file: string, ready: () => void) => { + expect(file).toBe(socket); steps.push("socket"); ready(); + } }; + new Function("require", "process", GROK_SOCKET_PLACEHOLDER_PROGRAM)( + (name: string) => name === "node:fs" ? fs : { createServer: () => server }, + { argv: ["node", socket, "2100", "2100"], exit: (status: number) => steps.push(`exit ${status}`) } + ); + expect(steps).toEqual(["socket", "mode 600", "owner 2100:2100", "exit 0"]); + }); + + it("never treats a missing socket, a file, or the unchanged placeholder as readiness", () => { + const previous = { dev: 1, ino: 2, ctimeMs: 3, isSocket: () => true }; + expect(isStartedBrokerSocket(undefined, previous)).toBe(false); + expect(isStartedBrokerSocket({ ...previous, isSocket: () => false }, undefined)).toBe(false); + expect(isStartedBrokerSocket({ ...previous }, previous)).toBe(false); + expect(isStartedBrokerSocket({ ...previous, ino: 4 }, previous)).toBe(true); + // Filesystems can immediately reuse an inode; its changed ctime proves replacement too. + expect(isStartedBrokerSocket({ ...previous, ctimeMs: 4 }, previous)).toBe(true); + }); +}); diff --git a/src/compiler/training/broker/processes.ts b/src/compiler/training/broker/processes.ts index 6971f3f1..42bb0a33 100644 --- a/src/compiler/training/broker/processes.ts +++ b/src/compiler/training/broker/processes.ts @@ -6,6 +6,11 @@ import { SpawnfileError } from "../../../shared/index.js"; import { DAIMON_GROK_ENGINE_BROKER } from "../../../runtime/daimon/contractManifest.js"; import { DAIMON_BROKER_UID, TRAINING_BROKER_TMPDIR } from "./paths.js"; +interface SocketIdentity { dev: number; ino: number; ctimeMs: number; isSocket(): boolean } +/** An existing placement socket is not readiness. Its inode (or reuse timestamp) must change. */ +export const isStartedBrokerSocket = (current: SocketIdentity | undefined, previous: SocketIdentity | undefined): boolean => + current?.isSocket() === true && (!previous || current.dev !== previous.dev || current.ino !== previous.ino || current.ctimeMs !== previous.ctimeMs); + export const DAIMON_RUNTIME_ROOT = "/opt/spawnfile/runtime-installs/daimon"; /** `00000000000000c1` = CHOWN|SETGID|SETUID, the launcher's bounding set after `setpriv`. */ export const LAUNCHER_CAPABILITY_BOUND = "00000000000000c1"; @@ -67,6 +72,7 @@ export const startBrokerProcesses = async (options: StartBrokerOptions): Promise const deadline = Date.now() + timeoutMs; try { for (const entry of brokerProcessPlan()) { + const previousSocket = await stat(entry.socket).catch(() => undefined); const child = spawn(entry.argv[0]!, entry.argv.slice(1), { stdio: ["ignore", "inherit", "inherit"] }); if (child.pid === undefined) throw new SpawnfileError("runtime_error", `${entry.name} did not start`); let exited = false; @@ -76,7 +82,7 @@ export const startBrokerProcesses = async (options: StartBrokerOptions): Promise if (exited) throw new SpawnfileError("runtime_error", `${entry.name} exited before readiness`); if (Date.now() > deadline) throw new SpawnfileError("runtime_error", `${entry.name} readiness timed out`); const socket = await stat(entry.socket).catch(() => undefined); - if (socket?.isSocket()) { + if (isStartedBrokerSocket(socket, previousSocket)) { const identity = await processIdentity(child.pid, procRoot); if (identity.uid === String(entry.uid) && identity.capBnd === entry.capBnd) break; } diff --git a/src/compiler/training/broker/projection.test.ts b/src/compiler/training/broker/projection.test.ts new file mode 100644 index 00000000..78020b9b --- /dev/null +++ b/src/compiler/training/broker/projection.test.ts @@ -0,0 +1,65 @@ +import { createHash } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { DAIMON_GROK_ENGINE_BROKER } from "../../../runtime/daimon/contractManifest.js"; +import { parseTrainingBrokerDeclaration } from "./declaration.js"; +import { loadDaimonProjectionModule, resolveTrainingGrokProjection, type DaimonProjectionModule } from "./projection.js"; +import { buildTrainingSlotReceipt } from "./receipt.js"; +import { resolveTrainingGrokRegistration } from "./registration.js"; + +const broker = DAIMON_GROK_ENGINE_BROKER; +const declaration = parseTrainingBrokerDeclaration({ + version: "spawnfile.training-broker.v2", slots: 1, engine: "grok", agentId: "agent:author", model: "grok-4.6", reasoningEffort: "low", + architecture: "arm64", limits: broker.turnLimits.v1Defaults, bootstrap: "/var/lib/spawnfile/daimon/grok-bootstrap-auth", + organizationUid: 2000, seccompProfileSha256: "a".repeat(64) +}); +const registration = resolveTrainingGrokRegistration(declaration); + +/** A resolver that defaults to unclocked, like Daimon: removing the caller's variant must fail. */ +const moduleFor = (overrides: Record = {}): DaimonProjectionModule => ({ + GROK_ENGINE_BROKER: broker, + resolveOrganizationGrokBrokerProjection: (config, agentId, options) => { + expect((config as { agents: unknown[] }).agents).toHaveLength(1); + expect(agentId).toBe(registration.agentId); + const clocked = options.workerConfigVariant === "clocked"; + return { + version: broker.projectionVersion, denyPaths: registration.denyPaths, profileSha256: registration.profileSha256, + workerConfigVariant: clocked ? "clocked" : "unclocked", + workerConfigSha256: (clocked ? broker.worker.clockedConfigSha256 : broker.worker.configSha256)["grok-4.6"].low, + ...(clocked ? { taskClock: broker.worker.taskClock } : {}), ...overrides + }; + }, + grokBrokerProjectionSha256: (projection) => createHash("sha256").update(JSON.stringify(projection)).digest("hex") +}); + +describe("training clocked projection", () => { + it("binds the v3 variant and selected config digest into the slot receipt", async () => { + const daimon = await loadDaimonProjectionModule(async () => moduleFor()); + const result = await resolveTrainingGrokProjection(declaration, registration, daimon); + expect(result.projection).toMatchObject({ version: "noopolis.daimon.grok-broker-projection.v3", + workerConfigVariant: "clocked", workerConfigSha256: registration.configSha256, taskClock: broker.worker.taskClock }); + const receipt = buildTrainingSlotReceipt({ slot: 0, workerUid: 2200, generation: 1, nonce: "a".repeat(64), + projectionSha256: result.projectionSha256, sandboxProfileSha256: registration.profileSha256, + seccompProfileSha256: declaration.seccompProfileSha256, grokExecutableSha256: broker.grokCliArtifacts.arm64.sha256, + canaries: [{ path: registration.denyPaths[0]!, method: "sandboxed-read", result: "denied" }], createdAt: new Date(0) }); + expect(receipt.projection_sha256).toBe(daimon.grokBrokerProjectionSha256(result.projection)); + expect(receipt.projection_sha256).not.toBe(daimon.grokBrokerProjectionSha256({ ...result.projection, workerConfigVariant: "unclocked" })); + }); + + it.each([ + { version: "noopolis.daimon.grok-broker-projection.v1" }, { workerConfigVariant: "unclocked" }, + { workerConfigSha256: broker.worker.configSha256["grok-4.6"].low }, { taskClock: undefined }, + { taskClock: { ...broker.worker.taskClock, directoryMode: 0o777 } }, { profileSha256: "b".repeat(64) } + ])("refuses a mismatched clocked projection: %j", async (overrides) => { + await expect(resolveTrainingGrokProjection(declaration, registration, moduleFor(overrides))).rejects.toThrow(/clocked worker config/u); + }); + + it("refuses deny-list drift and an unclocked registration", async () => { + await expect(resolveTrainingGrokProjection(declaration, registration, moduleFor({ denyPaths: [] }))).rejects.toThrow(/deny list differs/u); + await expect(resolveTrainingGrokProjection(declaration, { ...registration, configVariant: "unclocked" }, moduleFor())).rejects.toThrow(/clocked worker config/u); + }); + + it.each(["projectionVersion", "controlProtocolVersion", "slotPreflightVersion", "nativeAbiVersion"] as const)("refuses an installed Daimon with an old %s", async (field) => { + await expect(loadDaimonProjectionModule(async () => ({ ...moduleFor(), GROK_ENGINE_BROKER: { ...broker, [field]: "old" } }))) + .rejects.toThrow(/differs from this compiler/u); + }); +}); diff --git a/src/compiler/training/broker/projection.ts b/src/compiler/training/broker/projection.ts index c2912e22..f8495178 100644 --- a/src/compiler/training/broker/projection.ts +++ b/src/compiler/training/broker/projection.ts @@ -17,7 +17,7 @@ import { trainingSlotPaths } from "./paths.js"; export interface DaimonProjectionModule { resolveOrganizationGrokBrokerProjection(config: unknown, agentId: string, options: Record): { version: string; profileSha256: string; denyPaths: readonly string[] }; grokBrokerProjectionSha256(projection: unknown): string; - GROK_ENGINE_BROKER: { projectionVersion: string; slotPreflightVersion: string; nativeAbiVersion: number }; + GROK_ENGINE_BROKER: { projectionVersion: string; slotPreflightVersion: string; nativeAbiVersion: number; controlProtocolVersion: string }; } /** Indirect specifier: Daimon is an in-image runtime peer of the training container, never a Spawnfile source dependency. */ @@ -32,6 +32,7 @@ export const loadDaimonProjectionModule = async (load: () => Promise = catch { throw new SpawnfileError("runtime_error", "The training image must install @noopolis/daimon with its public /runtime export"); } if (typeof daimon.resolveOrganizationGrokBrokerProjection !== "function" || typeof daimon.grokBrokerProjectionSha256 !== "function" || daimon.GROK_ENGINE_BROKER?.projectionVersion !== DAIMON_GROK_ENGINE_BROKER.projectionVersion + || daimon.GROK_ENGINE_BROKER.controlProtocolVersion !== DAIMON_GROK_ENGINE_BROKER.controlProtocolVersion || daimon.GROK_ENGINE_BROKER.slotPreflightVersion !== DAIMON_GROK_ENGINE_BROKER.slotPreflightVersion || daimon.GROK_ENGINE_BROKER.nativeAbiVersion !== DAIMON_GROK_ENGINE_BROKER.nativeAbiVersion) { throw new SpawnfileError("runtime_error", "The installed Daimon Grok broker contract differs from this compiler's vendored manifest"); @@ -96,7 +97,14 @@ export const resolveTrainingGrokProjection = async ( seccompProfileSha256: declaration.seccompProfileSha256, profileSha256: registration.profileSha256 }) as unknown as Record; - if (projection.workerConfigVariant !== "clocked" || projection.workerConfigSha256 !== registration.configSha256) { + const clock = DAIMON_GROK_ENGINE_BROKER.worker.taskClock; + const expectedClock = { ...clock, directoryPath: clock.slotDirectoryPattern.replace("{slot}", String(registration.slot)), + timestampFilePath: clock.slotFilePattern.replace("{slot}", String(registration.slot)) }; + const actualClock = projection.taskClock as Record | undefined; + if (registration.configVariant !== "clocked" || projection.version !== DAIMON_GROK_ENGINE_BROKER.projectionVersion + || projection.profileSha256 !== registration.profileSha256 || actualClock === undefined + || Object.entries(expectedClock).some(([key, value]) => actualClock[key] !== value) + || projection.workerConfigVariant !== "clocked" || projection.workerConfigSha256 !== registration.configSha256) { throw new SpawnfileError("runtime_error", "Daimon's clocked worker config differs from the provisioned registration"); } const denied = [...(projection.denyPaths as string[])]; diff --git a/src/compiler/training/broker/provisioning.ts b/src/compiler/training/broker/provisioning.ts index 9c5e4866..2b3ab3e8 100644 --- a/src/compiler/training/broker/provisioning.ts +++ b/src/compiler/training/broker/provisioning.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { renderTrainingTaskClockReset } from "./taskClock.js"; import { DAIMON_GROK_SECCOMP_PROFILE_SHA256 } from "../../../shared/daimonGrokSeccompProfile.js"; import { DAIMON_GROK_ENGINE_BROKER, DAIMON_GROK_TURN_USAGE_LEDGER } from "../../../runtime/daimon/contractManifest.js"; @@ -119,14 +120,13 @@ export const renderTrainingBrokerProvisioning = (input: DaimonGrokRegistration | const directories = [...new Map(registrations.flatMap(r => trainingSlotDirectories(r.slot)).map(d => [d.path, d])).values()]; return [ ...renderTrainingIdentities(registrations.length), + ...renderTrainingTaskClockReset(), ...renderDirectories(directories, [...registrations.map(r => r.usageLedgerPath), TRAINING_INFERENCE_LEDGER]), ...renderDaimonGrokHostPreflight(), ...renderDaimonBrokerProvisioningProgram(registrations, [], { version: "v3", maxRunRequests, runRequestStore: TRAINING_RUN_REQUEST_STORE, turnStore: TRAINING_TURN_STORE_ROOT, inferenceLedgerPath: TRAINING_INFERENCE_LEDGER }, "clear", TRAINING_OPTIONAL_DENY_DIRECTORIES, TRAINING_BROKER_TMPDIR, [ - // Placeholders exist for deny placement only; service startup must see absent socket paths. - ...TRAINING_BROKER_SOCKET_PATHS.map(socket => `touch ${quote(socket)}; chmod 000 ${quote(socket)}`), `mkdir -p ${quote(DAIMON_GROK_ENGINE_BROKER.worker.taskClock.rootDirectoryPath)}; chmod 0755 ${quote(DAIMON_GROK_ENGINE_BROKER.worker.taskClock.rootDirectoryPath)}`, ...registrations.flatMap(r => { const p = trainingSlotPaths(r.slot); @@ -137,7 +137,6 @@ export const renderTrainingBrokerProvisioning = (input: DaimonGrokRegistration | ]; }) ]), - ...TRAINING_BROKER_SOCKET_PATHS.map(socket => `rm ${quote(socket)}`), `chmod 0550 /etc/daimon-engine-broker; chown 0:${DAIMON_BROKER_UID} /etc/daimon-engine-broker`, ...registrations.flatMap(r => [...renderSlotAssertions(r), ...renderTrainingSealedInputsAssertions(r.uid, r.privateTmp)]) ]; diff --git a/src/compiler/training/broker/registration.test.ts b/src/compiler/training/broker/registration.test.ts index 96aa0301..d7beaa3c 100644 --- a/src/compiler/training/broker/registration.test.ts +++ b/src/compiler/training/broker/registration.test.ts @@ -53,7 +53,8 @@ describe("training Grok slot registration", () => { }); it("refuses masks over its clock or preload library", () => { - for (const denied of [trainingSlotPaths(0).clockDirectory, "/run/daimon-engine-broker/task-clock", "/usr/local/lib/daimon"]) { + for (const denied of [trainingSlotPaths(0).clockDirectory, "/run/daimon-engine-broker/task-clock", "/usr/local/lib/daimon", + "/usr/lib/x86_64-linux-gnu/faketime", "/usr/lib/aarch64-linux-gnu/faketime"]) { expect(() => resolveTrainingGrokDenyPaths([denied])).toThrow(/task clock/); } }); diff --git a/src/compiler/training/broker/registration.ts b/src/compiler/training/broker/registration.ts index a67057ae..57267f23 100644 --- a/src/compiler/training/broker/registration.ts +++ b/src/compiler/training/broker/registration.ts @@ -61,7 +61,8 @@ export const resolveTrainingGrokDenyPaths = (added: readonly string[] | undefine if (within(p.workspace, entry) || within(p.workerHome, entry) || within(p.runtimeHome, entry)) { fail(`Training Grok deny path ${entry} would hide the subject's own workspace, home, or runtime home`); } - if ([p.clockFile, DAIMON_GROK_ENGINE_BROKER.worker.taskClock.preloadPath].some(file => within(file, entry))) { + if ([p.clockFile, DAIMON_GROK_ENGINE_BROKER.worker.taskClock.preloadPath, + "/usr/lib/x86_64-linux-gnu/faketime/libfaketime.so.1", "/usr/lib/aarch64-linux-gnu/faketime/libfaketime.so.1"].some(file => within(file, entry))) { fail(`Training Grok deny path ${entry} would hide the subject's task clock`); } const ancestor = candidates.find((other) => other !== entry && within(entry, other)); diff --git a/src/compiler/training/broker/runtime.ts b/src/compiler/training/broker/runtime.ts index 21bd14e4..c0b24699 100644 --- a/src/compiler/training/broker/runtime.ts +++ b/src/compiler/training/broker/runtime.ts @@ -10,6 +10,7 @@ import type { DaimonGrokRegistration } from "../../containerDaimonGrokWorkerRend import type { TrainingBrokerDeclaration } from "./declaration.js"; import { MOUNT_AWARE_CLEAR_HELPER } from "../../containerDaimonBrokerRender.js"; import { probeTrainingSiblingClock } from "./clockProbe.js"; +import { renderTrainingTaskClockAssertions } from "./taskClock.js"; import { renderTrainingSlotProvisioning } from "./provisioning.js"; import { buildTrainingSlotReceipt, readGrokExecutableSha256, resolveTrainingCanaries } from "./receipt.js"; import type { TrainingSlotRuntime } from "./supervisor.js"; @@ -69,12 +70,15 @@ export const createTrainingSlotRuntime = (options: TrainingSlotRuntimeOptions): }); if (result.stdout.trim()) log(`provisioning: ${result.stdout.trim().slice(-2048)}`); }, - canaries: async () => resolveTrainingCanaries({ + canaries: async () => { + await run(shell, ["--noprofile", "--norc", "-ceu", renderTrainingTaskClockAssertions(options.registration).join("\n")]); + return resolveTrainingCanaries({ denyPaths: options.registration.denyPaths, probe, log, hostBindPaths: TRAINING_HOST_BIND_DENY_PATHS, sealedPaths: TRAINING_SEALED_DENY_PATHS, mountinfo: await readFile(`${options.procRoot ?? "/proc"}/self/mountinfo`, "utf8"), unenforcedBindPolicy: options.declaration.unenforcedBindPolicy - }), + }); + }, nextGeneration: async () => { const previous = await readFile(p.generationFile, "utf8").catch(() => ""); const parsed = Number.parseInt((JSON.parse(previous || "{}") as { generation?: unknown }).generation as string ?? "0", 10); diff --git a/src/compiler/training/broker/taskClock.test.ts b/src/compiler/training/broker/taskClock.test.ts new file mode 100644 index 00000000..d0dea97f --- /dev/null +++ b/src/compiler/training/broker/taskClock.test.ts @@ -0,0 +1,74 @@ +import { execFileSync } from "node:child_process"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { DAIMON_GROK_ENGINE_BROKER } from "../../../runtime/daimon/contractManifest.js"; +import { TRAINING_BROKER_SOCKET_PATHS } from "./paths.js"; +import { renderTrainingBrokerProvisioning } from "./provisioning.js"; +import { resolveTrainingGrokRegistration } from "./registration.js"; +import { renderTrainingTaskClockAssertions, renderTrainingTaskClockReset } from "./taskClock.js"; + +const slot = resolveTrainingGrokRegistration({ agentId: "agent:a", model: "grok-4.6", reasoningEffort: "low" }); +const clock = DAIMON_GROK_ENGINE_BROKER.worker.taskClock; + +describe("training task clock preflight", () => { + it("checks installed bytes, exact owners/modes, and worker readability without write authority", () => { + const lines = renderTrainingTaskClockAssertions(slot), script = lines.join("\n"); + execFileSync("/bin/bash", ["-n"], { input: script }); + expect(script).toContain(`sha256sum '${slot.grokHome}/config.toml'`); + expect(script).toContain(slot.configSha256); + expect(script).toContain('= "2100:2100 755"'); + expect(script).toContain('= "2100:2100 644 1"'); + const probe = lines.at(-1)!; + expect(probe).toContain("--reuid 2200 --regid 2200"); + // Quotes are shell-escaped by the outer command. Verify every access test survives rendering. + for (const [verb, target] of [["test -r", clock.timestampFilePath], ["test ! -w", clock.timestampFilePath], + ["test -x", clock.directoryPath], ["test ! -w", clock.directoryPath], ["test -r", clock.preloadPath]]) { + expect(probe).toContain(`${verb} '"'"'${target}'"'"'`); + } + expect(() => renderTrainingTaskClockAssertions({ ...slot, configVariant: "unclocked" })).toThrow(/pinned clocked/u); + expect(() => renderTrainingTaskClockAssertions({ ...slot, configSha256: "a".repeat(64) })).toThrow(/pinned clocked/u); + }); + + it("checks a nonzero slot's specialized config and clock without touching siblings", () => { + const selected = resolveTrainingGrokRegistration({ agentId: "agent:a", model: "grok-4.6", reasoningEffort: "low", slot: 3, slots: 4 }); + const script = renderTrainingTaskClockAssertions(selected).join("\n"); + execFileSync("/bin/bash", ["-n"], { input: script }); + expect(script).toContain("/task-clock/slot-3/faketime"); + expect(script).toContain("--reuid 2203 --regid 2203"); + expect(script).toContain(selected.configSha256); + expect(script).not.toContain("/task-clock/slot-0"); + expect(() => renderTrainingTaskClockAssertions({ ...selected, configSha256: slot.configSha256 })).toThrow(/pinned clocked/u); + }); + + it("rejects a wrong-type, writable, or worker-owned distro library or ancestor", () => { + const lines = renderTrainingTaskClockAssertions(slot); + const source = lines.slice(lines.indexOf("node <<'SPAWNFILE_TASK_CLOCK_LIBRARY'") + 1, lines.indexOf("SPAWNFILE_TASK_CLOCK_LIBRARY")).join("\n"); + const target = "/usr/lib/aarch64-linux-gnu/faketime/libfaketime.so.1"; + const run = (bad?: string, change = {}) => new Function("require", source)((name: string) => name === "node:path" ? path.posix : { + realpathSync: () => target, + statSync: (file: string) => ({ isFile: () => true, ...(file === bad ? change : {}) }), + lstatSync: (file: string) => ({ uid: 0, gid: 0, mode: file === target ? 0o644 : 0o755, + isSymbolicLink: () => file === clock.preloadPath, ...(file === bad ? change : {}) }) + }); + expect(() => run()).not.toThrow(); + expect(() => run(target, { isFile: () => false })).toThrow(/unexpected task clock library/u); + for (const file of [target, "/usr/lib/aarch64-linux-gnu/faketime", "/usr/local/lib/daimon"]) { + expect(() => run(file, { uid: 2200 })).toThrow(/unsafe task clock library path/u); + expect(() => run(file, { mode: 0o777 })).toThrow(/unsafe task clock library path/u); + } + expect(() => run(clock.preloadPath, { isSymbolicLink: () => false })).toThrow(/unsafe task clock preload link/u); + }); + + it("reclaims the clock subtree before clearing it and retains socket deny targets for startup attestation", () => { + const script = renderTrainingBrokerProvisioning(slot).join("\n"); + execFileSync("/bin/bash", ["-n"], { input: script }); + for (const line of renderTrainingTaskClockReset()) { + expect(script.indexOf(line)).toBeLessThan(script.indexOf('for broker_root in /etc/daimon-engine-broker')); + } + expect(script).toContain('unsafe broker socket placeholder'); + for (const socket of TRAINING_BROKER_SOCKET_PATHS) { + expect(script).toContain(JSON.stringify(socket)); + expect(script).not.toContain(`rm '${socket}'`); + } + }); +}); diff --git a/src/compiler/training/broker/taskClock.ts b/src/compiler/training/broker/taskClock.ts new file mode 100644 index 00000000..d9cffcee --- /dev/null +++ b/src/compiler/training/broker/taskClock.ts @@ -0,0 +1,45 @@ +import { DAIMON_GROK_ENGINE_BROKER } from "../../../runtime/daimon/contractManifest.js"; +import { SpawnfileError } from "../../../shared/index.js"; +import { resolveDaimonGrokClockedWorkerConfig } from "../../../runtime/daimon/grokWorkerContract.js"; +import type { DaimonGrokRegistration } from "../../containerDaimonGrokWorkerRender.js"; + +const clock = DAIMON_GROK_ENGINE_BROKER.worker.taskClock; +const quote = (value: string): string => `'${value.replace(/'/gu, `'"'"'`)}'`; + +/** Reclaim only the clock subtree before the shared program clears the control root at startup. */ +export const renderTrainingTaskClockReset = (): string[] => [ + `test ! -L ${quote(clock.rootDirectoryPath)}`, + `if [ -d ${quote(clock.rootDirectoryPath)} ]; then chown -R 0:0 ${quote(clock.rootDirectoryPath)}; chmod -R u+rwX ${quote(clock.rootDirectoryPath)}; fi` +]; + +/** Before a receipt, bind the actual installed bytes and worker access to the clocked projection. */ +export const renderTrainingTaskClockAssertions = (registration: DaimonGrokRegistration): string[] => { + const pin = resolveDaimonGrokClockedWorkerConfig(registration.model, registration.reasoningEffort, registration.slot).sha256; + const directoryPath = clock.slotDirectoryPattern.replace("{slot}", String(registration.slot)); + const timestampFilePath = clock.slotFilePattern.replace("{slot}", String(registration.slot)); + if (registration.configVariant !== "clocked" || registration.configSha256 !== pin) { + throw new SpawnfileError("runtime_error", "Training slot requires the pinned clocked worker config"); + } + const worker = [ + `test -r ${quote(timestampFilePath)}`, `test ! -w ${quote(timestampFilePath)}`, + `test -x ${quote(directoryPath)}`, `test ! -w ${quote(directoryPath)}`, + `test -r ${quote(clock.preloadPath)}` + ].join(" && "); + return [ + `test ! -L ${quote(directoryPath)} && test -d ${quote(directoryPath)}`, + `test ! -L ${quote(timestampFilePath)} && test -f ${quote(timestampFilePath)}`, + `test "$(stat -c '%u:%g %a' ${quote(directoryPath)})" = "${clock.ownerUid}:${clock.ownerUid} ${clock.directoryMode.toString(8)}"`, + `test "$(stat -c '%u:%g %a %h' ${quote(timestampFilePath)})" = "${clock.ownerUid}:${clock.ownerUid} ${clock.fileMode.toString(8)} 1"`, + `test "$(sha256sum ${quote(`${registration.grokHome}/config.toml`)} | cut -d ' ' -f1)" = ${quote(pin)}`, + // The read-only image bakes both the fixed symlink and the distro target. Refuse writable or + // non-root-owned ancestors/targets; a missing library must never masquerade as a working clock. + "node <<'SPAWNFILE_TASK_CLOCK_LIBRARY'", + "const fs = require('node:fs'), path = require('node:path');", + `const preload = ${JSON.stringify(clock.preloadPath)};`, + "const link = fs.lstatSync(preload); if (!link.isSymbolicLink() || link.uid !== 0 || link.gid !== 0) throw new Error('unsafe task clock preload link');", + "const library = fs.realpathSync(preload); if (!/^\\/usr\\/lib\\/(x86_64|aarch64)-linux-gnu\\/faketime\\/libfaketime\\.so\\.1$/.test(library) || !fs.statSync(library).isFile()) throw new Error('unexpected task clock library');", + "for (const root of [path.dirname(preload), library]) { for (let at = root;; at = path.dirname(at)) { const info = fs.lstatSync(at); if (info.isSymbolicLink() || info.uid !== 0 || info.gid !== 0 || (info.mode & 0o022)) throw new Error(`unsafe task clock library path: ${at}`); if (at === '/') break; } }", + "SPAWNFILE_TASK_CLOCK_LIBRARY", + `setpriv --clear-groups --reuid ${registration.uid} --regid ${registration.uid} --inh-caps=-all --ambient-caps=-all --bounding-set=-all -- /bin/sh -ceu ${quote(worker)}` + ]; +}; diff --git a/src/compiler/training/preparation/contextModes.test.ts b/src/compiler/training/preparation/contextModes.test.ts index 090f4f41..3c769505 100644 --- a/src/compiler/training/preparation/contextModes.test.ts +++ b/src/compiler/training/preparation/contextModes.test.ts @@ -59,9 +59,12 @@ it("stages COPY content with the modes the former chmod 0555 train/train-broker expect(await modes(candidate)).toEqual(expected); // Documented expectation: directories 0755, readable files 0644, executables 0755, train/train-broker 0555. expect(expected).toMatchObject({ bridge: "755", "bridge/pkg": "755", "bridge/requirements.lock": "644", "bridge/pkg/run.sh": "755", - "integration/entry.ts": "444", "integration/bin/partial": "555", "train-broker": "555", train: "555", sticky: "2755", "sticky/file": "4755" }); + "integration/entry.ts": "444", "integration/bin/partial": "555", "train-broker": "555", train: "555" }); expect((await lstat(path.join(candidate, "bridge/pkg/tool.py"))).mtime).toEqual(TRAINING_CONTEXT_MTIME); expect(readableClosure(0o600, false)).toBe(0o644); + // The OS may clear special bits during chmod; the real chmod comparison above owns that parity. + expect(readableClosure(0o2700, true)).toBe(0o2755); + expect(readableClosure(0o4700, false)).toBe(0o4755); }); it("hands Docker a normalized context and never runs a layer after the distribution copies", async () => { @@ -99,6 +102,7 @@ it("hands Docker a normalized context and never runs a layer after the distribut "spawnfile/package.json", "spawnfile/runtimes.yaml", "train", "train-broker"]); expect(instructions.filter(line => line.startsWith("COPY --from"))).toHaveLength(2); expect(dockerfile.match(/ln -s \S+ \S+/gu)).toEqual([ + 'ln -s "$library" /usr/local/lib/daimon/libfaketime.so.1', "ln -s /opt/spawnfile/runtime-installs/daimon/node_modules/@noopolis/daimon node_modules/@noopolis/daimon", "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", diff --git a/src/deployment/lifecycleCompletion.test.ts b/src/deployment/lifecycleCompletion.test.ts index bc258041..c58ea429 100644 --- a/src/deployment/lifecycleCompletion.test.ts +++ b/src/deployment/lifecycleCompletion.test.ts @@ -7,6 +7,7 @@ import { mkdtemp, mkdir, readFile, + realpath, rename, rm, symlink, @@ -108,7 +109,7 @@ describe("lifecycle completion store", () => { role: "initial", }); if (process.platform === "darwin") { - expect(os.tmpdir().startsWith("/var/")).toBe(true); + expect(await realpath("/var")).toBe("/private/var"); } }); diff --git a/src/deployment/organizationHandoffAuthorityFsBudget.test.ts b/src/deployment/organizationHandoffAuthorityFsBudget.test.ts index ad43621e..a7cea6b0 100644 --- a/src/deployment/organizationHandoffAuthorityFsBudget.test.ts +++ b/src/deployment/organizationHandoffAuthorityFsBudget.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createOrganizationHandoffAuthorityError, @@ -66,19 +66,25 @@ describe("organization handoff authority budget", () => { expect(budget.patient()).toBe(false); }); - it("grows and jitters the backoff without ever waiting past the deadline", async () => { + it.each([0, 0.525, 0.999999])("grows and jitters backoff within the deadline (random=%s)", async (random) => { const time = clock(); const delays: number[] = []; const budget = new OrganizationHandoffAuthorityBudget({ limitMs: 100, now: time.now, sleep: async (ms) => { delays.push(ms); time.advance(ms); } }); - for (let index = 0; index < 40 && !budget.exhausted(); index += 1) await budget.wait(); - expect(delays.length).toBeGreaterThan(1); - // Jittered, so never assert exact values: assert the envelope instead. - for (const delay of delays) expect(delay).toBeGreaterThanOrEqual(0); - expect(Math.max(...delays)).toBeLessThanOrEqual(25); - expect(delays.slice(-1)[0]).toBeGreaterThan(delays[0] as number); - expect(time.now()).toBeLessThanOrEqual(100); + const jitter = vi.spyOn(Math, "random").mockReturnValue(random); + try { + for (let index = 0; index < 40 && !budget.exhausted(); index += 1) await budget.wait(); + const factor = 0.5 + random * 0.5; + expect(delays.slice(0, 6)).toEqual([1, 2, 4, 8, 16, 25].map((base) => base * factor)); + for (const delay of delays) expect(delay).toBeGreaterThanOrEqual(0); + expect(Math.max(...delays)).toBeLessThanOrEqual(25); + expect(time.now()).toBe(100); + // The deadline can make the final wait smaller than the first. + if (random === 0.525) expect(delays.at(-1)).toBeLessThan(delays[0] as number); + } finally { + jitter.mockRestore(); + } }); it("ships defaults that fit inside one client request", () => { diff --git a/src/runtime/daimon/AGENTS.md b/src/runtime/daimon/AGENTS.md index 0b2826ae..cfcfee78 100644 --- a/src/runtime/daimon/AGENTS.md +++ b/src/runtime/daimon/AGENTS.md @@ -70,6 +70,13 @@ samples). The manifest also pins Grok CLI 1.0.34 (URL + sha256 per architecture), which the runtime image, the local builder, and generated organization images all verify. +Manifest v5 carries a separate clocked config matrix, slot-routed control protocol v4 +and projection v3. Training explicitly selects `clocked`; the default production +matrix is byte-identical. Re-vendoring changes the whole-manifest authority: +the old published pin must fail compilation until publication and re-pinning, +even for an unclocked organization. Never weaken the image or schedule/attention +receipt gates to bridge that release gap. + `wakeMoments.ts` mirrors the optional data-only wake recording capability; `contractManifest.ts` requires it and `agents[].recording` together. Its presence in the receipt-bound manifest, never a version guess, permits recording. diff --git a/src/target/AGENTS.md b/src/target/AGENTS.md index d03a6027..666e707b 100644 --- a/src/target/AGENTS.md +++ b/src/target/AGENTS.md @@ -100,6 +100,8 @@ zero blocks, and no trailing data or malformed extensions. - `index.ts` is the public barrel for this boundary. - Tests prove hostile input rejection and exact public serialization. +- Private target selection and command execution accept configured executable paths + up to 1,024 UTF-8 bytes, matching the CLI configuration limit. Keep this public boundary independent of Docker, CLI, runtime, organization, world, and simulation behavior. Public values are bounded identifiers, canonical digests, diff --git a/src/target/dockerCommandExecutorCore.test.ts b/src/target/dockerCommandExecutorCore.test.ts new file mode 100644 index 00000000..f959e437 --- /dev/null +++ b/src/target/dockerCommandExecutorCore.test.ts @@ -0,0 +1,37 @@ +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; + +import { expect, it } from "vitest"; + +import { executeDockerCommandCore, type DockerCommandSpawn } from "./dockerCommandExecutorCore.js"; + +class Child extends EventEmitter { + public readonly stdin = new PassThrough(); + public readonly stdout = new PassThrough(); + public readonly stderr = new PassThrough(); + public kill(): boolean { return true; } +} + +it("executes configured paths up to 1024 UTF-8 bytes without a shell and rejects longer paths before spawn", async () => { + const calls: unknown[] = []; + const spawn: DockerCommandSpawn = (file, args, options) => { + calls.push([file, args, options]); + const child = new Child(); + queueMicrotask(() => { + child.stdout.end("receipt\n"); + child.stderr.end(); + child.emit("close", 0); + }); + return child as unknown as ReturnType; + }; + const file = `/${"é".repeat(511)}x`; + expect(Buffer.byteLength(file, "utf8")).toBe(1_024); + await expect(executeDockerCommandCore(spawn, file, ["version"], { binary: false, timeout: 100 })) + .resolves.toEqual({ stderr: "", stdout: "receipt\n" }); + expect(calls).toEqual([[file, ["version"], { shell: false, stdio: ["pipe", "pipe", "pipe"] }]]); + for (const invalid of [`${file}x`, `/${"é".repeat(512)}`, "docker\0private"]) { + expect(() => executeDockerCommandCore(spawn, invalid, ["version"], { binary: false, timeout: 100 })) + .toThrow("Docker command failed"); + } + expect(calls).toHaveLength(1); +}); diff --git a/src/target/dockerCommandExecutorCore.ts b/src/target/dockerCommandExecutorCore.ts index faf170bc..ff8a0a56 100644 --- a/src/target/dockerCommandExecutorCore.ts +++ b/src/target/dockerCommandExecutorCore.ts @@ -76,7 +76,7 @@ const validate: ( args: unknown, options: unknown ) => asserts options is DockerCommandCoreOptions = (file, args, options) => { - if (typeof file !== "string" || file.length < 1 || file.length > 128 || file.includes("\0") + if (typeof file !== "string" || file.length < 1 || Buffer.byteLength(file, "utf8") > 1_024 || file.includes("\0") || Buffer.from(file, "utf8").toString("utf8") !== file || !Array.isArray(args) || nodeTypes.isProxy(args) || Object.getPrototypeOf(args) !== Array.prototype || args.length > 1_024 diff --git a/src/target/dockerTarget.test.ts b/src/target/dockerTarget.test.ts index 365d5708..75046270 100644 --- a/src/target/dockerTarget.test.ts +++ b/src/target/dockerTarget.test.ts @@ -13,6 +13,25 @@ const endpoint = "ssh://deploy@example.com"; const executor = (stdout = JSON.stringify(endpoint)): DockerTargetExecFile => async () => ({ stderr: "", stdout }); describe("selectTarget", () => { + it("accepts configured executable paths up to 1024 UTF-8 bytes and rejects invalid commands before execution", async () => { + const calls: string[] = []; + const execFile: DockerTargetExecFile = async (file) => { + calls.push(file); + return { stderr: "", stdout: JSON.stringify(endpoint) }; + }; + const dockerCommand = `/${"é".repeat(511)}x`; + expect(Buffer.byteLength(dockerCommand, "utf8")).toBe(1_024); + await expect(selectTarget({ context, dockerCommand, execFile })).resolves.toMatchObject({ + fingerprint: createEndpointFingerprint(endpoint) + }); + for (const invalid of ["", `${dockerCommand}x`, `/${"é".repeat(512)}`, "docker\0private"]) { + await expect(selectTarget({ context, dockerCommand: invalid, execFile })).rejects.toMatchObject({ + code: "runtime_error", message: "Target selection failed" + }); + } + expect(calls).toEqual([dockerCommand]); + }); + it("inspects exactly one named context and returns a strict secret-free receipt", async () => { const calls: Array<[string, string[], { signal?: AbortSignal; timeout: number }]> = []; const receipt = await selectTarget({ diff --git a/src/target/dockerTargetBinding.ts b/src/target/dockerTargetBinding.ts index 6cdd7a2a..79e2b87c 100644 --- a/src/target/dockerTargetBinding.ts +++ b/src/target/dockerTargetBinding.ts @@ -72,7 +72,8 @@ const isDockerContextEndpoint = (endpoint: string): boolean => const selectTargetOptions = (input: SelectTargetOptions): Required> & Pick => { - if (input.dockerCommand !== undefined && (typeof input.dockerCommand !== "string" || input.dockerCommand.length === 0 || input.dockerCommand.length > 128)) { + if (input.dockerCommand !== undefined && (typeof input.dockerCommand !== "string" || input.dockerCommand.length === 0 + || input.dockerCommand.includes("\0") || Buffer.byteLength(input.dockerCommand, "utf8") > 1_024)) { return selectionFailure("validation_error"); } if (input.timeoutMs !== undefined && (typeof input.timeoutMs !== "number" || !Number.isSafeInteger(input.timeoutMs) || input.timeoutMs < 1 || input.timeoutMs > 120_000)) {