From 196c603144d7bb7d5296dd252c21fc756e9b230b Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 11 Oct 2026 08:23:45 +0200 Subject: [PATCH 1/8] feat: vendor the Daimon clocked Grok broker contract --- runtime-images/daimon/publish-inputs.json | 2 +- scripts/vendor-daimon-grok-contract.ts | 7 +++++- src/compiler/publicDaimonHost.test.ts | 9 +++----- src/compiler/recordingCapability.test.ts | 18 +++++++++++---- src/compiler/recordingReport.test.ts | 7 +++--- src/compiler/upProject.recording.test.ts | 3 ++- src/compiler/workspaceGitPins.test.ts | 7 +++--- src/compiler/workspaceRecordingOff.test.ts | 7 +++--- src/runtime/container.test.ts | 12 +++------- src/runtime/daimon/AGENTS.md | 7 ++++++ src/runtime/daimon/adapter.test.ts | 11 +++++----- src/runtime/daimon/attention.test.ts | 4 ++-- src/runtime/daimon/contract-manifest.json | 2 +- src/runtime/daimon/contract-manifest.sha256 | 2 +- src/runtime/daimon/contractManifest.test.ts | 2 +- src/runtime/daimon/contractManifest.ts | 22 +++++++++++++++---- src/runtime/daimon/grokWorkerConfigBytes.ts | 19 ++++++++++++++++ src/runtime/daimon/grokWorkerContract.test.ts | 15 ++++++++++--- src/runtime/daimon/grokWorkerContract.ts | 9 +++++--- src/runtime/daimon/recording.test-helper.ts | 22 ++++++++++++++++--- src/runtime/daimon/scheduleAuthority.test.ts | 11 +++------- src/runtime/install.test.ts | 5 ++++- 22 files changed, 139 insertions(+), 64 deletions(-) diff --git a/runtime-images/daimon/publish-inputs.json b/runtime-images/daimon/publish-inputs.json index f8c5894a..458dda54 100644 --- a/runtime-images/daimon/publish-inputs.json +++ b/runtime-images/daimon/publish-inputs.json @@ -19,7 +19,7 @@ "version": "0.142.3" }, "daimon": { - "commit": "834955934e102349335362c37524261625e38dbb", + "commit": "7758bd70b8ba33a63bb772195574be33a7950029", "package_version": "0.2.0", "source": "https://github.com/noopolis/daimon-harness.git" }, diff --git a/scripts/vendor-daimon-grok-contract.ts b/scripts/vendor-daimon-grok-contract.ts index 74d02e56..fe63b22e 100644 --- a/scripts/vendor-daimon-grok-contract.ts +++ b/scripts/vendor-daimon-grok-contract.ts @@ -25,7 +25,7 @@ export const PROFILE_SAMPLE_DENY_PATHS: readonly (readonly string[])[] = [ type DaimonModules = { contractManifestArtifacts: () => { digest: Buffer; manifest: Buffer }; - renderGrokBrokerWorkerConfig: (policy: { model: string; reasoningEffort: string }) => string; + renderGrokBrokerWorkerConfig: (policy: { model: string; reasoningEffort: string }, variant?: "unclocked" | "clocked") => string; renderGrokWorkerSandboxProfile: (denyPaths: readonly string[]) => string; }; @@ -46,6 +46,8 @@ const loadDaimon = async (daimonDirectory: string): Promise => { export const renderVendoredWorkerModule = (daimon: Pick): string => { const configs = Object.fromEntries(models.map((model) => [model, Object.fromEntries(efforts.map((reasoningEffort) => [reasoningEffort, daimon.renderGrokBrokerWorkerConfig({ model, reasoningEffort })]))])); + const clockedConfigs = Object.fromEntries(models.map((model) => [model, Object.fromEntries(efforts.map((reasoningEffort) => + [reasoningEffort, daimon.renderGrokBrokerWorkerConfig({ model, reasoningEffort }, "clocked")]))])); const samples = PROFILE_SAMPLE_DENY_PATHS.map((denyPaths) => ({ bytes: daimon.renderGrokWorkerSandboxProfile(denyPaths), denyPaths })); return [ "/* v8 ignore file -- generated data module */", @@ -54,6 +56,9 @@ export const renderVendoredWorkerModule = (daimon: Pick { }); describe("public Daimon host fixture", () => { - it("compiles a public host against the CI-published Daimon index with no local identity", async () => { + it("requires a re-pin before production compiles can use the new manifest", async () => { const outputDirectory = await mkdtemp(path.join(os.tmpdir(), "spawnfile-public-daimon-")); temporaryDirectories.push(outputDirectory); - await compileProject(fixture, { outputDirectory }); - const dockerfile = await readUtf8File(path.join(outputDirectory, "Dockerfile")); - expect(dockerfile).toContain("COPY --from=noopolis/spawnfile-runtime-daimon@sha256:85875f67af630e8b4e667a72f20f0b491940cfb3feb7fe132a13a84678147747 "); - expect(dockerfile).toContain('amd64) expected="sha256:4714bbf377acdb098fcf90c0c91e55494f0996fca77e28cd599b6a72c7cac2dc"'); - expect(dockerfile).toContain('arm64) expected="sha256:f48018b729b984fde30af433607087c2f72019aad59ccf26d2641f18757c2dc6"'); + await expect(compileProject(fixture, { outputDirectory })).rejects.toThrow(/does not attest the compiler's exact contract manifest/u); + await expect(readUtf8File(path.join(outputDirectory, "Dockerfile"))).rejects.toThrow(); }); it("compiles against an explicit local identity without changing production registry pins", async () => { diff --git a/src/compiler/recordingCapability.test.ts b/src/compiler/recordingCapability.test.ts index 9545077d..92b65ba6 100644 --- a/src/compiler/recordingCapability.test.ts +++ b/src/compiler/recordingCapability.test.ts @@ -2,11 +2,11 @@ import { readFile, readdir } from "node:fs/promises"; import path from "node:path"; import { afterEach, expect, it, vi } from "vitest"; -import { capableDaimonManifest } from "../runtime/daimon/recording.test-helper.js"; +import { capableDaimonManifest, useMatchingDaimonTestImage } from "../runtime/daimon/recording.test-helper.js"; import { compileProject } from "./compileProject.js"; import { cleanTrainingProjects, trainingProject, writeTrainingFile } from "./training/plan/fixtures.test-helper.js"; -afterEach(async () => { vi.useRealTimers(); await cleanTrainingProjects(); }); +afterEach(async () => { vi.useRealTimers(); vi.unstubAllEnvs(); await cleanTrainingProjects(); }); const agent = (runtime: string, recording = true) => ({ spawnfile_version: "0.1", kind: "agent", name: "writer", runtime, ...(recording ? { training: { record: { keep: "2h", volumes: ["state"] } } } : {}), @@ -21,13 +21,22 @@ it.each(["openclaw", "picoclaw", "pi"])("rejects recording for %s with the exact it("rejects recording against the actual pinned image and requires a release and re-pin", async () => { const root = await trainingProject({ Spawnfile: agent("daimon") }); - await expect(compileProject(root)).rejects.toMatchObject({ code: "validation_error", message: expect.stringContaining( - "pinned Daimon runtime image does not declare the wakeMoments capability; a Daimon release with it and a runtimes.yaml / contract-manifest re-pin are required" + await expect(compileProject(root)).rejects.toMatchObject({ code: "runtime_error", message: expect.stringContaining( + "Selected Daimon runtime image does not attest the compiler's exact contract manifest" ) }); }); +it("still refuses a manifest without recording authority", async () => { + const root = await trainingProject({ Spawnfile: agent("daimon") }); + const { wakeMoments, ...manifest } = capableDaimonManifest(); + const incapable = { ...manifest, + consumedConfigFields: manifest.consumedConfigFields.filter((field) => field !== wakeMoments!.configField) }; + await expect(compileProject(root, { daimonContractManifest: incapable })).rejects.toThrow(/does not declare the wakeMoments capability/u); +}); + it("compiles an injected capable contract, emits snapshots at their mounted backing path, and protects the store", async () => { const root = await trainingProject({ Spawnfile: agent("daimon") }); + useMatchingDaimonTestImage(root); const result = await compileProject(root, { daimonContractManifest: capableDaimonManifest() }); const config = JSON.parse(await readFile(path.join(result.outputDirectory, "container/rootfs/var/lib/spawnfile/instances/daimon/daimon-organization/daimon/daimon-organization-runtime.json"), "utf8")); @@ -53,6 +62,7 @@ const emittedFiles = async (directory: string, relative = ""): Promise { const manifest = agent("daimon", false); const root = await trainingProject({ Spawnfile: manifest }); + useMatchingDaimonTestImage(root); vi.useFakeTimers({ toFake: ["Date"] }); vi.setSystemTime(new Date("2026-10-09T08:20:31Z")); const before = await compileProject(root); const bytes = await emittedFiles(before.outputDirectory); diff --git a/src/compiler/recordingReport.test.ts b/src/compiler/recordingReport.test.ts index cbbf0df8..0c3512cd 100644 --- a/src/compiler/recordingReport.test.ts +++ b/src/compiler/recordingReport.test.ts @@ -1,16 +1,16 @@ import path from "node:path"; import { readFile } from "node:fs/promises"; -import { afterEach, expect, it } from "vitest"; +import { afterEach, expect, it, vi } from "vitest"; import { parseDistributionReport } from "../distribution/index.js"; -import { capableDaimonManifest } from "../runtime/daimon/recording.test-helper.js"; +import { capableDaimonManifest, useMatchingDaimonTestImage } from "../runtime/daimon/recording.test-helper.js"; import { buildCompilePlan } from "./buildCompilePlan.js"; import { compileProject } from "./compileProject.js"; import { createMemoryArtifactBundle } from "./memoryArtifacts.js"; import { resolveRecordingReport } from "./recordingReport.js"; import { cleanTrainingProjects, trainingProject, writeTrainingFile } from "./training/plan/fixtures.test-helper.js"; -afterEach(cleanTrainingProjects); +afterEach(async () => { vi.unstubAllEnvs(); await cleanTrainingProjects(); }); const memory = (id: string) => ({ id, store: { kind: "json", path: `/data/${id}/events.jsonl` } }); const agent = (name: string, training?: unknown) => ({ spawnfile_version: "0.1", kind: "agent", name, runtime: "daimon", memory: [memory(name)], ...(training ? { training } : {}) }); @@ -32,6 +32,7 @@ it("uses only compiled recording agents, protecting their own and accessible sha it("emits the opt-in field in real compile and distribution reports without loading training targets", async () => { const root = await trainingProject({ Spawnfile: agent("a") }); + useMatchingDaimonTestImage(root); const off = await compileProject(root); expect(off.report).not.toHaveProperty("recording"); // Use the compiler's emitted report path, which the Dockerfile copies into the image. diff --git a/src/compiler/upProject.recording.test.ts b/src/compiler/upProject.recording.test.ts index fd13c489..34eb85ff 100644 --- a/src/compiler/upProject.recording.test.ts +++ b/src/compiler/upProject.recording.test.ts @@ -6,7 +6,7 @@ import path from "node:path"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { readDeployResources, readDeploymentRecord, deployResourceLogPath, deployResourcesAt, downDeployment } from "../deployment/index.js"; -import { capableDaimonManifest } from "../runtime/daimon/recording.test-helper.js"; +import { capableDaimonManifest, useMatchingDaimonTestImage } from "../runtime/daimon/recording.test-helper.js"; import { compileProject } from "./compileProject.js"; import { upProject } from "./upProject.js"; @@ -22,6 +22,7 @@ vi.mock("../deployment/index.js", async (original) => ({ let root: string; beforeEach(() => { root = mkdtempSync(path.join(os.tmpdir(), "spawnfile-up-recording-")); + useMatchingDaimonTestImage(root); vi.stubEnv("SPAWNFILE_HOME", path.join(root, "home")); vi.stubEnv("NOOPOLIS_RUN_ID", "recording-test"); }); diff --git a/src/compiler/workspaceGitPins.test.ts b/src/compiler/workspaceGitPins.test.ts index 22686ecb..4a0e48c3 100644 --- a/src/compiler/workspaceGitPins.test.ts +++ b/src/compiler/workspaceGitPins.test.ts @@ -4,11 +4,11 @@ import { chmod, mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "n import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createDeployResources, deployResourcesAt } from "../deployment/index.js"; import { DISTRIBUTION_REPORT_OUTPUT_FILE, parseDistributionReport } from "../distribution/index.js"; -import { capableDaimonManifest } from "../runtime/daimon/recording.test-helper.js"; +import { capableDaimonManifest, useMatchingDaimonTestImage } from "../runtime/daimon/recording.test-helper.js"; import { compileProject } from "./compileProject.js"; import type { CompilePlan } from "./types.js"; import { resolveWorkspaceGitPins } from "./workspaceGitPins.js"; @@ -54,7 +54,7 @@ describe("fetch: build git resources", () => { await writeFile(path.join(source, "2026-10-08/stories/s1.md"), "# story one\n"); await writeFile(path.join(source, "README.md"), "private\n"); }); - afterEach(async () => { await rm(root, { force: true, recursive: true }); }); + afterEach(async () => { vi.unstubAllEnvs(); await rm(root, { force: true, recursive: true }); }); it("pins a moving branch to a commit at compile time, mounts it as a read-only bundle and reproduces it from the recorded commit", async () => { const first = await commitDay("s1 first\n"); @@ -200,6 +200,7 @@ describe("fetch: build git resources", () => { .replace("name: reporter", `name: ${agent}`).replace("runtime: openclaw", "runtime: daimon") .replace("provider: anthropic", "provider: openai").replace("method: claude-code", "method: codex")); } + useMatchingDaimonTestImage(root); const compiled = await compile("recording-out", { daimonContractManifest: capableDaimonManifest() }); const pins = compiled.report.workspace_git_resources!; expect(new Set(pins.map((pin) => pin.id)).size).toBe(1); diff --git a/src/compiler/workspaceRecordingOff.test.ts b/src/compiler/workspaceRecordingOff.test.ts index 3d3a647d..c808d728 100644 --- a/src/compiler/workspaceRecordingOff.test.ts +++ b/src/compiler/workspaceRecordingOff.test.ts @@ -4,10 +4,10 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; -import { afterEach, beforeEach, expect, it } from "vitest"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { createDistributionFingerprint, DISTRIBUTION_REPORT_OUTPUT_FILE, type DistributionReport } from "../distribution/index.js"; -import { capableDaimonManifest } from "../runtime/daimon/recording.test-helper.js"; +import { capableDaimonManifest, useMatchingDaimonTestImage } from "../runtime/daimon/recording.test-helper.js"; import { compileProject } from "./compileProject.js"; const run = promisify(execFile); @@ -39,9 +39,10 @@ beforeEach(async () => { })); } }); -afterEach(async () => { await rm(root, { recursive: true, force: true }); }); +afterEach(async () => { vi.unstubAllEnvs(); await rm(root, { recursive: true, force: true }); }); const compile = async (recording: boolean) => { + if (recording) useMatchingDaimonTestImage(root); if (recording) for (const name of ["a", "b"]) { const file = path.join(org, name, "Spawnfile"), manifest = JSON.parse(await readFile(file, "utf8")); await writeFile(file, json({ ...manifest, runtime: "daimon", execution: undefined })); diff --git a/src/runtime/container.test.ts b/src/runtime/container.test.ts index 6f2495ac..19396d1c 100644 --- a/src/runtime/container.test.ts +++ b/src/runtime/container.test.ts @@ -89,12 +89,8 @@ describe("runtime container install recipes", () => { ]); }); - it("copies the CI-published Daimon index and checks the per-architecture receipt by default", async () => { - const recipe = await createRuntimeInstallRecipe("daimon"); - expect(recipe.copyCommands).toEqual([ - `COPY --from=noopolis/spawnfile-runtime-daimon@sha256:85875f67af630e8b4e667a72f20f0b491940cfb3feb7fe132a13a84678147747 ${RUNTIME_INSTALL_ROOT}/daimon ${RUNTIME_INSTALL_ROOT}/daimon` - ]); - expect(recipe.commands[0]).toContain('case "$arch" in amd64) expected="sha256:4714bbf377acdb098fcf90c0c91e55494f0996fca77e28cd599b6a72c7cac2dc" ;; arm64) expected="sha256:f48018b729b984fde30af433607087c2f72019aad59ccf26d2641f18757c2dc6" ;;'); + it("requires a re-pin because the published Daimon image attests the old manifest", async () => { + await expect(createRuntimeInstallRecipe("daimon")).rejects.toThrow(/does not attest the compiler's exact contract manifest/u); }); it("installs overridden runtime packages from the vendored tarball path in the Pi recipe", async () => { @@ -209,9 +205,7 @@ describe("runtime container install recipes", () => { it("ignores the legacy Daimon base-image override without bypassing the manifest gate", async () => { process.env.SPAWNFILE_DAIMON_RUNTIME_BASE_IMAGE = "noopolis/spawnfile-runtime-daimon:legacy"; - const recipe = await createRuntimeInstallRecipe("daimon"); - expect(recipe.baseImage).toBeUndefined(); - expect(recipe.copyCommands[0]).toContain("noopolis/spawnfile-runtime-daimon@sha256:85875f67af630e8b4e667a72f20f0b491940cfb3feb7fe132a13a84678147747"); + await expect(createRuntimeInstallRecipe("daimon")).rejects.toThrow(/does not attest the compiler's exact contract manifest/u); }); it("omits NOOPOLIS_RUN_ID from every recipe's env when unset", async () => { diff --git a/src/runtime/daimon/AGENTS.md b/src/runtime/daimon/AGENTS.md index 0b2826ae..7a152f28 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 v4 adds a separate clocked config matrix, control protocol v3 and +projection v2. 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/runtime/daimon/adapter.test.ts b/src/runtime/daimon/adapter.test.ts index f1f68c12..e96bb804 100644 --- a/src/runtime/daimon/adapter.test.ts +++ b/src/runtime/daimon/adapter.test.ts @@ -382,24 +382,23 @@ describe("daimonAdapter", () => { ); }); - it("uses the pinned image because it attests the exact compiler contract", async () => { - const recipe = await createRuntimeInstallRecipe("daimon"); - expect(recipe.copyCommands[0]).toContain("noopolis/spawnfile-runtime-daimon@sha256:85875f67af630e8b4e667a72f20f0b491940cfb3feb7fe132a13a84678147747"); + it("requires a re-pin for the new compiler contract", async () => { + await expect(createRuntimeInstallRecipe("daimon")).rejects.toThrow(/does not attest the compiler's exact contract manifest/u); }); - it("lowers schedules while retaining the MCP and non-Moltnet surface boundary", async () => { + it("degrades schedules pending a re-pin and retains the MCP and non-Moltnet surface boundary", async () => { await expect(daimonAdapter.compileAgent(createDaimonNode("schedule", "Schedule"))).resolves.toBeDefined(); await expect(daimonAdapter.compileAgent(createPiTestNode({ runtime: { name: "daimon", options: {} }, schedule: { every: "1m", kind: "every", prompt: "work" } }))).resolves.toMatchObject({ capabilities: expect.arrayContaining([ - expect.objectContaining({ key: "agent.schedule", outcome: "supported" }) + expect.objectContaining({ key: "agent.schedule", outcome: "degraded" }) ]) }); await expect(daimonAdapter.compileAgent(createPiTestNode({ runtime: { name: "daimon", options: {} }, schedule: { kind: "disabled" } }))).resolves.toMatchObject({ capabilities: expect.arrayContaining([ - expect.objectContaining({ key: "agent.schedule", outcome: "supported" }) + expect.objectContaining({ key: "agent.schedule", outcome: "degraded" }) ]) }); await expect(daimonAdapter.compileAgent(createPiTestNode({ runtime: { name: "daimon", options: { engine: "grok" } } diff --git a/src/runtime/daimon/attention.test.ts b/src/runtime/daimon/attention.test.ts index 069cc5a8..469bfd6e 100644 --- a/src/runtime/daimon/attention.test.ts +++ b/src/runtime/daimon/attention.test.ts @@ -50,8 +50,8 @@ describe("Daimon attention policy compilation", () => { await attest(); expect((await compile({})).agents[0].attention).toEqual({ maxBatchMessages: 8, maxBatchBytes: 12000 }); }); - it("accepts attention against the checked-in published pin without a local identity", async () => { - expect((await compile({ max_batch_messages: 2 })).agents[0].attention).toEqual({ maxBatchMessages: 2, maxBatchBytes: 12000 }); + it("requires a re-pin before the published image grants attention authority", async () => { + await expect(compile({ max_batch_messages: 2 })).rejects.toThrow(/attention is disabled.*does not attest/u); }); it.each([ null, [], "auto", { typo: 1 }, { max_batch_messages: 0 }, { max_batch_messages: 33 }, diff --git a/src/runtime/daimon/contract-manifest.json b/src/runtime/daimon/contract-manifest.json index a72ae883..0488ef6e 100644 --- a/src/runtime/daimon/contract-manifest.json +++ b/src/runtime/daimon/contract-manifest.json @@ -1 +1 @@ -{"activityResponseSchema":{"additionalProperties":false,"properties":{"items":{"items":{"additionalProperties":false,"properties":{"agentId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"id":{"format":"uuid","pattern":"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$","type":"string"},"kind":{"enum":["wake_started","wake_completed","wake_rejected","wake_aborted","agent_stopped"]},"occurredAt":{"format":"date-time","pattern":"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$","type":"string"},"wakeId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"}},"required":["id","agentId","kind","occurredAt"],"type":"object"},"maxItems":100,"type":"array"},"nextCursor":{"maxLength":16,"minLength":1,"pattern":"^(0|[1-9][0-9]{0,15})$","type":"string"},"version":{"const":"noopolis.daimon.organization-runtime-activity.v1"}},"required":["version","items"],"type":"object"},"activityV2ResponseSchema":{"additionalProperties":false,"properties":{"executions":{"items":{"additionalProperties":false,"properties":{"agent_id":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"delivery_ids":{"items":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"maxItems":32,"type":"array"},"execution_id":{"type":"string"},"state":{"const":"running"}},"required":["agent_id","execution_id","state","delivery_ids"],"type":"object"},"maxItems":32,"type":"array"},"items":{"items":{"additionalProperties":false,"properties":{"acceptance_id":{"type":"string"},"accepted_at":{"format":"date-time","pattern":"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$","type":"string"},"active":{"type":"boolean"},"agent_id":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"code":{"enum":["engine_failed","host_stopped","host_stopping","queued_wake_stopped","active_wake_aborted","queue_full","unknown_agent"]},"deferred":{"type":"boolean"},"delivery_id":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"execution_id":{"type":"string"},"queue_position":{"minimum":1,"type":"integer"},"request_digest":{"type":"string"},"state":{"enum":["accepted","running","completed","failed","stopped"]},"text":{"maxLength":16384,"type":"string"},"updated_at":{"format":"date-time","pattern":"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$","type":"string"},"version":{"const":"noopolis.daimon.wake-receipt-status.v2"}},"required":["version","acceptance_id","agent_id","delivery_id","request_digest","state","accepted_at","updated_at","active"],"type":"object"},"maxItems":2112,"type":"array"},"state":{"enum":["running","stopped"]},"version":{"const":"noopolis.daimon.organization-runtime-activity.v2"}},"required":["version","items"],"type":"object"},"agySubscriptionRealm":{"directoryMode":448,"durableMountPath":"/var/lib/spawnfile/daimon/agy-subscription-realm","fileMode":384,"maxUnlockBytes":4096,"unlockMountPath":"/var/lib/spawnfile/daimon/agy-unlock-secret","unlockSourceSlot":"agy-unlock-secret"},"attention":{"accounting":"execution-start-reservations","busyDispatch":"bounded-pending-message-batch","completion":"explicit-per-delivery","defaultMaxBatchBytes":12000,"defaultMaxBatchMessages":8,"enabledBy":"agents[].attention","idleDispatch":"immediate","unhandled":"deferred-until-new-input"},"consumedConfigFields":["version","host.bindHost","host.port","host.controlTokenEnv","agents[].id","agents[].name","agents[].instructions","agents[].workspacePath","agents[].runtimeHomePath","agents[].engine.kind","agents[].engine.model","agents[].engine.reasoningEffort","agents[].engine.codexSandbox","agents[].schedule.kind","agents[].schedule.interval_ms","agents[].schedule.cron","agents[].schedule.timezone","agents[].schedule.prompt","agents[].schedule.jitter_seconds","agents[].mcp","agents[].moltnet","agents[].memory","agents[].attention"],"deliverySemantics":{"activeDeliveryIdempotency":"unbounded-until-terminal","concurrentSameAgentTurns":false,"externalEffectsExactlyOnce":false,"recovery":"at-least-once-with-stable-wake-id","terminalReceiptHorizon":2048},"engineCredentialMaterial":{"codex":{"destinationRelativePath":".codex/auth.json","directoryMode":448,"fileMode":384,"sourceRelativePath":".daimon-inbound/codex-auth","sourceSlot":"codex-auth"}},"grokEngineBroker":{"artifacts":{"arm64Sha256":"d0dc1c03197c3e90f09b66ad7c89b4f7b448d06dc84c03b247dce976d6018d6e","sourceSha256":"15836d7ad69f12ef581a6e5b8635c9db43b2177dac609160c0749d3d1fefbaa8","x64Sha256":"95aa9c272225e74ff9f45f54dde4bc30aee324e9ba24d8a876db9151c81b454d"},"backendSocketPath":"/run/daimon-engine-broker/backend.sock","bounds":{"capabilityBundleBytes":8196,"capabilityBytes":4096,"outputBytes":262144,"promptBytes":65536},"controlProtocolVersion":"noopolis.daimon.engine-broker.v2","controlSocketPath":"/run/daimon-engine-broker/control.sock","credentialHomePath":"/var/lib/spawnfile/daimon/grok-subscription-realm","grokCliArtifacts":{"arm64":{"bytes":136090504,"sha256":"39ab87666877d64ef3a40aa60fbe0c3b6a6acd7001b78fe60e2c76bb6cfc4a94","url":"https://storage.googleapis.com/grok-build-public-artifacts/cli/grok-1.0.34-linux-aarch64"},"x64":{"bytes":163035648,"sha256":"be5905e107d2b8b5f3c142d21ecfe4c8fd32a913d2fd551b788707930c4dc80d","url":"https://storage.googleapis.com/grok-build-public-artifacts/cli/grok-1.0.34-linux-x86_64"}},"grokCliBuild":"3736acbc8658","grokCliVersion":"1.0.34","grokExecutablePath":"/usr/local/bin/grok","identities":{"brokerUid":2100,"firstWorkerUid":2200,"organizationUid":2000},"inferenceGrants":{"bodyMembers":["messages","model","reasoning_effort","response_format","stream","stream_options"],"client":{"configSha256":{"grok-4.5":{"high":"ffbc33728b821e9854fbc7c93601e599225da421ecfd6ebf10d314afcc28d6f2","low":"a07f7436f1268bb399ec233c65d3b3d8fb99a11a1f175f8da1ca133c9367bc74","medium":"1f4c0d4dad1f3b09419b5739db6423a09e0049abc091594c64123a75dd53dfb9"},"grok-4.6":{"high":"5652656effa82f0c4f09cf8226b16e6140332a5a358b571194bb5563312367ac","low":"79314d039f787e4ebfec7dacf57adc969086b948f564dec008f0ed6367e6062f","medium":"6f538de0547c0c4e6a3f04ae08595ceadadabb06b75f6b6ee4c428744bb95cd8"},"grok-build":{"high":"98d16f2b7d12f4eb540d625c853e51d227933e204923e43e8b9b4176f10aca2c","low":"ca15c6a562a008227d39c51d3a3a83715663089b3784e8b46debb1fb67b3c4a1","medium":"01783fb6beadcf6f8486fff0836820ad43fab5662b812a907fe9cfdb83e9804d"}},"envKey":"DAIMON_INFERENCE_GRANT","modelId":"daimon-inference-grok"},"failureCodes":["auth_stale","grant_limit","invalid_request","unavailable"],"ledgerDedupeKey":["grant","request"],"ledgerVersion":"noopolis.daimon.inference-usage.v1","limits":{"maxRequests":64,"maxTokens":2000000},"maxInFlightRequestsPerGrant":1,"maxLiveGrants":8,"messageRoles":["system","user","assistant"],"purposes":["judge","optimizer"],"requestKinds":["request_inference_grant","release_inference_grant"],"tokenPrefix":"inference_","ttlMs":600000},"launcherSocketPath":"/run/daimon-engine-broker/launcher.sock","mcpFacade":{"host":"127.0.0.1","path":"/mcp","port":43124},"nativeAbiVersion":2,"nativeExecutablePath":"/opt/daimon/bin/daimon-engine-broker","projectionVersion":"noopolis.daimon.grok-broker-projection.v1","providerProxy":{"host":"127.0.0.1","port":43123},"registrationPath":"/etc/daimon-engine-broker/registrations.bin","serviceConfigPath":"/etc/daimon-engine-broker/service.json","serviceConfigVersions":["noopolis.daimon.engine-broker-service.v1","noopolis.daimon.engine-broker-service.v2"],"slotPreflightVersion":"noopolis.daimon.grok-slot-preflight.v2","turnLimits":{"bounds":{"maxRequests":[1,48],"maxTokens":[1,10000000],"timeoutMs":[1000,3600000]},"keys":["maxRequests","maxTokens","timeoutMs"],"limitReasons":["tokens","requests","timeout","none"],"maxInFlightRequests":1,"missingUsageEstimate":{"inputBytesPerToken":2,"outputTokens":4096},"requestUsageMaxTokens":500000,"tokenCeilingOvershoot":"at-most-one-request","v1Defaults":{"maxRequests":32,"maxTokens":300000,"timeoutMs":240000},"wakeMayOnlyLower":true},"turnRecordVersions":["noopolis.daimon.engine-broker-turn.v1","noopolis.daimon.engine-broker-turn.v2"],"turnStorePath":"/var/lib/spawnfile/daimon/grok-subscription-realm/turns","wakeLimitEnvironment":{"maxTokens":"DAIMON_ENGINE_WAKE_TOKEN_CEILING","timeoutMs":"DAIMON_ENGINE_WAKE_TIMEOUT_MS"},"worker":{"configSha256":{"grok-4.5":{"high":"c63c3387ce92d94ec3f690abfe98942afcd7c9e17ff84816bbe751f340ab251f","low":"8247127c3625ff7c5d8d527a53596b89ec6557a821ac46cfd00bd122b90daff6","medium":"59288cee61297bb8c002097061a48f77b09d310754187a253ee089f7172a9155"},"grok-4.6":{"high":"65b0212564fb74042b1503d293fb8d3620276033264c0efade2a539ca09218e3","low":"ab58499ac32678097c146479896f2b8a8e2b0e39aea22dc0a60b6227e370538e","medium":"df1a5cc84346e7f6bf6090492fbd19faaefb42953e3bb2e8c6cbc0572242403f"},"grok-build":{"high":"a23724e00d670caee185ba7690d2daa868173e53905cf446f5329666f01ab4e3","low":"fb343f2809903f26d21681470943235031f946e99085542fd89555eb7782cbb5","medium":"8a587ef75c90eab70d19b24583e60051d6fba9d90c558839fdbb15588b4cc656"}},"defaultModel":"grok-4.6","defaultReasoningEffort":"low","home":{"directory":{"group":"worker","mode":1017,"uid":0},"organizationRuntimeHome":{"group":"worker","mode":456,"owner":"organization"},"privateTmp":{"mode":448,"owner":"worker","relativeToWorkerHome":"tmp"},"readOnlyFiles":{"gid":0,"mode":292,"names":["config.toml","managed_config.toml","requirements.toml","sandbox.toml","trusted_folders.toml"],"uid":0},"sandboxEvents":{"group":"broker","mode":416,"owner":"worker","relativePath":"sessions/sandbox-events.jsonl"},"sessionsDirectory":{"group":"worker","mode":1017,"relativePath":"sessions","uid":0},"sharedTmp":{"maxGroupExclusive":2200,"mode":1020,"otherMode":4,"paths":["/tmp","/var/tmp"],"uid":0},"spillDirectory":{"fileMode":416,"group":"worker","mode":1512,"owner":"organization","relativeToRuntimeHome":"tool-output"}},"maxTurns":48,"modelId":"daimon-broker-grok","models":["grok-4.6","grok-4.5","grok-build"],"reasoningEfforts":["low","medium","high"],"systemPromptSha256":"2c31c0085a54a4efbf9c0cf0b8124c56e47f38691b7f0c7fa233a74abaa8ddf8","toolIds":["run_terminal_cmd","read_file","grep","list_dir","search_tool","use_tool"],"visibleTools":["grep","list_dir","read_file","run_terminal_command","search_tool","use_tool"]}},"grokSubscriptionRealm":{"agentCredentialRelativePath":".grok/auth.json","bootstrapMountPath":"/var/lib/spawnfile/daimon/grok-bootstrap-auth","bootstrapSourceSlot":"grok-auth","directoryMode":448,"durableMountPath":"/var/lib/spawnfile/daimon/grok-subscription-realm","fileMode":384,"maxCredentialBytes":65536},"healthResponseSchema":{"additionalProperties":false,"properties":{"agents":{"items":{"additionalProperties":false,"properties":{"agentId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"state":{"enum":["starting","running","stopping","stopped","idle","failed"]}},"required":["agentId","state"],"type":"object"},"maxItems":32,"type":"array"},"state":{"enum":["starting","running","stopping","stopped"]},"version":{"const":"noopolis.daimon.organization-runtime-health.v1"}},"required":["version","state","agents"],"type":"object"},"operatorDrain":{"authorization":"control-bearer","blockedReason":"operator_stop","drainRoute":"POST /v2/drain","queuedWakes":"retained","response":"workAvailabilityResponseSchema","resumeClearsOperatorStop":false,"resumeRoute":"POST /v2/resume","runningTurns":"finish","scope":"process"},"organizationRuntimeConfigSchema":{"$id":"noopolis.daimon.organization-runtime.v1","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"agents":{"items":{"additionalProperties":false,"properties":{"attention":{"additionalProperties":false,"properties":{"maxBatchBytes":{"maximum":12000,"minimum":1024,"type":"integer"},"maxBatchMessages":{"maximum":32,"minimum":1,"type":"integer"},"maxExecutions":{"maximum":9007199254740991,"minimum":1,"type":"integer"},"maxTokens":{"maximum":9007199254740991,"minimum":1,"type":"integer"}},"type":"object"},"engine":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"grok"}}},"then":{"dependentRequired":{"model":["reasoningEffort"],"reasoningEffort":["model"]},"properties":{"codexSandbox":false,"model":{"enum":["grok-4.6","grok-4.5","grok-build"]},"reasoningEffort":{"enum":["low","medium","high"]}}}},{"if":{"properties":{"kind":{"const":"agy"}}},"then":{"properties":{"codexSandbox":false,"model":false,"reasoningEffort":false}}}],"properties":{"codexSandbox":{"additionalProperties":false,"properties":{"mode":{"const":"workspace-write"},"networkAccess":{"const":false},"webSearch":{"const":"disabled"}},"required":["mode","networkAccess","webSearch"],"type":"object"},"kind":{"enum":["codex","grok","agy"]},"model":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"reasoningEffort":{"enum":["none","minimal","low","medium","high","xhigh","max","ultra","persistent"]}},"required":["kind"],"type":"object"},"id":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"instructions":{"maxLength":16384,"minLength":1,"pattern":"\\S","type":"string"},"mcp":{"items":{"additionalProperties":false,"properties":{"args":{"items":{"maxLength":4096,"type":"string"},"maxItems":32,"type":"array"},"authSecretEnv":{"pattern":"^[A-Za-z_][A-Za-z0-9_]*$","type":"string"},"command":{"pattern":"^/","type":"string"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"name":{"maxLength":4096,"minLength":1,"type":"string"},"tools":{"items":{"maxLength":4096,"minLength":1,"type":"string"},"maxItems":32,"minItems":1,"type":"array","uniqueItems":true},"transport":{"enum":["stdio","sse","streamable_http"]},"url":{"type":"string"}},"required":["name","transport","args","env","tools"],"type":"object"},"maxItems":8,"type":"array"},"memory":{"additionalProperties":false,"properties":{"runtimeHomePath":{"maxLength":4096,"pattern":"^/","type":"string"},"source":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"tokenBudget":{"maximum":1000000,"minimum":1,"type":"integer"}},"required":["runtimeHomePath"],"type":"object"},"moltnet":{"additionalProperties":false,"properties":{"cliPath":{"pattern":"^/","type":"string"},"configPath":{"pattern":"^/","type":"string"},"networks":{"items":{"additionalProperties":false,"properties":{"dms":{"type":"boolean"},"id":{"minLength":1,"type":"string"},"rooms":{"items":{"minLength":1,"type":"string"},"type":"array","uniqueItems":true}},"required":["id","rooms","dms"],"type":"object"},"maxItems":16,"type":"array"}},"required":["cliPath","configPath","networks"],"type":"object"},"name":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"runtimeHomePath":{"maxLength":4096,"pattern":"^/","type":"string"},"workspacePath":{"maxLength":4096,"pattern":"^/","type":"string"}},"required":["id","name","instructions","workspacePath","runtimeHomePath","engine"],"type":"object"},"maxItems":32,"minItems":1,"type":"array"},"host":{"additionalProperties":false,"properties":{"bindHost":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"controlTokenEnv":{"maxLength":4096,"pattern":"^[A-Za-z_][A-Za-z0-9_]*$","type":"string"},"port":{"maximum":65535,"minimum":1,"type":"integer"}},"required":["bindHost","port","controlTokenEnv"],"type":"object"},"version":{"const":"noopolis.daimon.organization-runtime.v1"}},"required":["version","host","agents"],"type":"object"},"organizationRuntimeConfigV2Schema":{"$id":"noopolis.daimon.organization-runtime.v2","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"agents":{"items":{"additionalProperties":false,"properties":{"attention":{"additionalProperties":false,"properties":{"maxBatchBytes":{"maximum":12000,"minimum":1024,"type":"integer"},"maxBatchMessages":{"maximum":32,"minimum":1,"type":"integer"},"maxExecutions":{"maximum":9007199254740991,"minimum":1,"type":"integer"},"maxTokens":{"maximum":9007199254740991,"minimum":1,"type":"integer"}},"type":"object"},"engine":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"grok"}}},"then":{"dependentRequired":{"model":["reasoningEffort"],"reasoningEffort":["model"]},"properties":{"codexSandbox":false,"model":{"enum":["grok-4.6","grok-4.5","grok-build"]},"reasoningEffort":{"enum":["low","medium","high"]}}}},{"if":{"properties":{"kind":{"const":"agy"}}},"then":{"properties":{"codexSandbox":false,"model":false,"reasoningEffort":false}}}],"properties":{"codexSandbox":{"additionalProperties":false,"properties":{"mode":{"const":"workspace-write"},"networkAccess":{"const":false},"webSearch":{"const":"disabled"}},"required":["mode","networkAccess","webSearch"],"type":"object"},"kind":{"enum":["codex","grok","agy"]},"model":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"reasoningEffort":{"enum":["none","minimal","low","medium","high","xhigh","max","ultra","persistent"]}},"required":["kind"],"type":"object"},"id":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"instructions":{"maxLength":16384,"minLength":1,"pattern":"\\S","type":"string"},"mcp":{"items":{"additionalProperties":false,"properties":{"args":{"items":{"maxLength":4096,"type":"string"},"maxItems":32,"type":"array"},"authSecretEnv":{"pattern":"^[A-Za-z_][A-Za-z0-9_]*$","type":"string"},"command":{"pattern":"^/","type":"string"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"name":{"maxLength":4096,"minLength":1,"type":"string"},"tools":{"items":{"maxLength":4096,"minLength":1,"type":"string"},"maxItems":32,"minItems":1,"type":"array","uniqueItems":true},"transport":{"enum":["stdio","sse","streamable_http"]},"url":{"type":"string"}},"required":["name","transport","args","env","tools"],"type":"object"},"maxItems":8,"type":"array"},"memory":{"additionalProperties":false,"properties":{"runtimeHomePath":{"maxLength":4096,"pattern":"^/","type":"string"},"source":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"tokenBudget":{"maximum":1000000,"minimum":1,"type":"integer"}},"required":["runtimeHomePath"],"type":"object"},"moltnet":{"additionalProperties":false,"properties":{"cliPath":{"pattern":"^/","type":"string"},"configPath":{"pattern":"^/","type":"string"},"networks":{"items":{"additionalProperties":false,"properties":{"dms":{"type":"boolean"},"id":{"minLength":1,"type":"string"},"rooms":{"items":{"minLength":1,"type":"string"},"type":"array","uniqueItems":true}},"required":["id","rooms","dms"],"type":"object"},"maxItems":16,"type":"array"}},"required":["cliPath","configPath","networks"],"type":"object"},"name":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"runtimeHomePath":{"maxLength":4096,"pattern":"^/","type":"string"},"schedule":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"disabled"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"interval_ms":{"maximum":31536000000,"minimum":1,"type":"integer"},"jitter_seconds":{"maximum":3600,"minimum":0,"type":"integer"},"kind":{"const":"every"},"prompt":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"}},"required":["kind","interval_ms","prompt"],"type":"object"},{"additionalProperties":false,"properties":{"cron":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"jitter_seconds":{"maximum":3600,"minimum":0,"type":"integer"},"kind":{"const":"cron"},"prompt":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"timezone":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"}},"required":["kind","cron","timezone","prompt"],"type":"object"}]},"workspacePath":{"maxLength":4096,"pattern":"^/","type":"string"}},"required":["id","name","instructions","workspacePath","runtimeHomePath","engine","schedule"],"type":"object"},"maxItems":32,"minItems":1,"type":"array"},"host":{"additionalProperties":false,"properties":{"bindHost":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"controlTokenEnv":{"maxLength":4096,"pattern":"^[A-Za-z_][A-Za-z0-9_]*$","type":"string"},"port":{"maximum":65535,"minimum":1,"type":"integer"}},"required":["bindHost","port","controlTokenEnv"],"type":"object"},"version":{"const":"noopolis.daimon.organization-runtime.v2"}},"required":["version","host","agents"],"type":"object"},"supportedEngineKinds":["agy","codex","grok"],"version":"noopolis.daimon.runtime-contract-manifest.v3","wakeAcceptanceTypes":["manual","message","schedule","external"],"wakeRequestSchema":{"additionalProperties":false,"properties":{"agentId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"event":{"additionalProperties":false,"properties":{"id":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"kind":{"enum":["manual","message","schedule","external"]},"occurredAt":{"format":"date-time","pattern":"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$","type":"string"},"text":{"maxLength":4096,"type":"string"},"version":{"const":"noopolis.daimon.wake.v1"}},"required":["version","id","kind","text","occurredAt"],"type":"object"}},"required":["agentId","event"],"type":"object"},"wakeResultSchema":{"oneOf":[{"additionalProperties":false,"properties":{"agentId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"durationMs":{"minimum":0,"type":"integer"},"status":{"const":"completed"},"text":{"maxLength":4096,"type":"string"},"version":{"const":"noopolis.daimon.wake-result.v1"},"wakeId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"}},"required":["version","status","agentId","wakeId","text","durationMs"],"type":"object"},{"additionalProperties":false,"properties":{"agentId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"code":{"enum":["unauthorized","unknown_agent","queue_full","durable_inbox_required"]},"status":{"const":"rejected"},"version":{"const":"noopolis.daimon.wake-result.v1"},"wakeId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"}},"required":["version","status","agentId","wakeId","code"],"type":"object"},{"additionalProperties":false,"properties":{"agentId":{"maxLength":4096,"type":"string"},"code":{"const":"invalid_request"},"status":{"const":"rejected"},"version":{"const":"noopolis.daimon.wake-result.v1"},"wakeId":{"maxLength":4096,"type":"string"}},"required":["version","status","agentId","wakeId","code"],"type":"object"},{"additionalProperties":false,"properties":{"agentId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"code":{"enum":["host_stopping","host_stopped","queued_wake_stopped","active_wake_aborted"]},"status":{"const":"stopped"},"version":{"const":"noopolis.daimon.wake-result.v1"},"wakeId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"}},"required":["version","status","agentId","wakeId","code"],"type":"object"},{"additionalProperties":false,"properties":{"agentId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"code":{"const":"engine_failed"},"status":{"const":"failed"},"version":{"const":"noopolis.daimon.wake-result.v1"},"wakeId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"}},"required":["version","status","agentId","wakeId","code"],"type":"object"}]},"workAvailabilityResponseSchema":{"additionalProperties":false,"properties":{"agents":{"items":{"additionalProperties":false,"properties":{"agent_id":{"type":"string"},"budget":{"additionalProperties":false,"properties":{"agent_executions_remaining":{"minimum":0,"type":"integer"},"agent_executions_used":{"minimum":0,"type":"integer"},"agent_tokens_remaining":{"minimum":0,"type":"integer"},"agent_tokens_used":{"minimum":0,"type":"integer"},"armed":{"type":"boolean"},"epoch":{"type":"string"},"executions_remaining":{"minimum":0,"type":"integer"},"executions_used":{"minimum":0,"type":"integer"},"reason":{"type":"string"},"state":{"enum":["available","paused","stopped"]},"tokens_remaining":{"minimum":0,"type":"integer"},"tokens_used":{"minimum":0,"type":"integer"}},"required":["armed","epoch","state","executions_used","executions_remaining","tokens_used","tokens_remaining","agent_executions_used","agent_tokens_used"],"type":"object"},"deferred":{"minimum":0,"type":"integer"},"error":{"type":"string"},"pending":{"minimum":0,"type":"integer"},"running":{"type":"boolean"}},"required":["agent_id","pending","running","deferred","budget"],"type":"object"},"maxItems":32,"type":"array"},"drain":{"additionalProperties":false,"properties":{"since":{"type":"string"},"state":{"enum":["draining","drained"]}},"required":["state","since"],"type":"object"},"state":{"enum":["running","paused","stopped"]},"version":{"const":"noopolis.daimon.work-availability.v1"}},"required":["version","state","agents"],"type":"object"},"workBlockedSchema":{"additionalProperties":false,"properties":{"reason":{"enum":["operator_stop","ledger_unavailable","host_stopping","host_stopped","queue_full"]},"retry_after_ms":{"maximum":300000,"minimum":1000,"type":"integer"},"version":{"const":"noopolis.daimon.work-blocked.v1"}},"required":["version","reason","retry_after_ms"],"type":"object"}} +{"activityResponseSchema":{"additionalProperties":false,"properties":{"items":{"items":{"additionalProperties":false,"properties":{"agentId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"id":{"format":"uuid","pattern":"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$","type":"string"},"kind":{"enum":["wake_started","wake_completed","wake_rejected","wake_aborted","agent_stopped"]},"occurredAt":{"format":"date-time","pattern":"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$","type":"string"},"wakeId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"}},"required":["id","agentId","kind","occurredAt"],"type":"object"},"maxItems":100,"type":"array"},"nextCursor":{"maxLength":16,"minLength":1,"pattern":"^(0|[1-9][0-9]{0,15})$","type":"string"},"version":{"const":"noopolis.daimon.organization-runtime-activity.v1"}},"required":["version","items"],"type":"object"},"activityV2ResponseSchema":{"additionalProperties":false,"properties":{"executions":{"items":{"additionalProperties":false,"properties":{"agent_id":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"delivery_ids":{"items":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"maxItems":32,"type":"array"},"execution_id":{"type":"string"},"state":{"const":"running"}},"required":["agent_id","execution_id","state","delivery_ids"],"type":"object"},"maxItems":32,"type":"array"},"items":{"items":{"additionalProperties":false,"properties":{"acceptance_id":{"type":"string"},"accepted_at":{"format":"date-time","pattern":"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$","type":"string"},"active":{"type":"boolean"},"agent_id":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"code":{"enum":["engine_failed","host_stopped","host_stopping","queued_wake_stopped","active_wake_aborted","queue_full","unknown_agent"]},"deferred":{"type":"boolean"},"delivery_id":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"execution_id":{"type":"string"},"queue_position":{"minimum":1,"type":"integer"},"request_digest":{"type":"string"},"state":{"enum":["accepted","running","completed","failed","stopped"]},"text":{"maxLength":16384,"type":"string"},"updated_at":{"format":"date-time","pattern":"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$","type":"string"},"version":{"const":"noopolis.daimon.wake-receipt-status.v2"}},"required":["version","acceptance_id","agent_id","delivery_id","request_digest","state","accepted_at","updated_at","active"],"type":"object"},"maxItems":2112,"type":"array"},"state":{"enum":["running","stopped"]},"version":{"const":"noopolis.daimon.organization-runtime-activity.v2"}},"required":["version","items"],"type":"object"},"agySubscriptionRealm":{"directoryMode":448,"durableMountPath":"/var/lib/spawnfile/daimon/agy-subscription-realm","fileMode":384,"maxUnlockBytes":4096,"unlockMountPath":"/var/lib/spawnfile/daimon/agy-unlock-secret","unlockSourceSlot":"agy-unlock-secret"},"attention":{"accounting":"execution-start-reservations","busyDispatch":"bounded-pending-message-batch","completion":"explicit-per-delivery","defaultMaxBatchBytes":12000,"defaultMaxBatchMessages":8,"enabledBy":"agents[].attention","idleDispatch":"immediate","unhandled":"deferred-until-new-input"},"consumedConfigFields":["version","host.bindHost","host.port","host.controlTokenEnv","agents[].id","agents[].name","agents[].instructions","agents[].workspacePath","agents[].runtimeHomePath","agents[].engine.kind","agents[].engine.model","agents[].engine.reasoningEffort","agents[].engine.codexSandbox","agents[].schedule.kind","agents[].schedule.interval_ms","agents[].schedule.cron","agents[].schedule.timezone","agents[].schedule.prompt","agents[].schedule.jitter_seconds","agents[].mcp","agents[].moltnet","agents[].memory","agents[].attention","agents[].recording"],"deliverySemantics":{"activeDeliveryIdempotency":"unbounded-until-terminal","concurrentSameAgentTurns":false,"externalEffectsExactlyOnce":false,"recovery":"at-least-once-with-stable-wake-id","terminalReceiptHorizon":2048},"engineCredentialMaterial":{"codex":{"destinationRelativePath":".codex/auth.json","directoryMode":448,"fileMode":384,"sourceRelativePath":".daimon-inbound/codex-auth","sourceSlot":"codex-auth"}},"grokEngineBroker":{"artifacts":{"arm64Sha256":"d0dc1c03197c3e90f09b66ad7c89b4f7b448d06dc84c03b247dce976d6018d6e","sourceSha256":"15836d7ad69f12ef581a6e5b8635c9db43b2177dac609160c0749d3d1fefbaa8","x64Sha256":"95aa9c272225e74ff9f45f54dde4bc30aee324e9ba24d8a876db9151c81b454d"},"backendSocketPath":"/run/daimon-engine-broker/backend.sock","bounds":{"capabilityBundleBytes":8196,"capabilityBytes":4096,"outputBytes":262144,"promptBytes":65536},"controlProtocolVersion":"noopolis.daimon.engine-broker.v3","controlSocketPath":"/run/daimon-engine-broker/control.sock","credentialHomePath":"/var/lib/spawnfile/daimon/grok-subscription-realm","grokCliArtifacts":{"arm64":{"bytes":136090504,"sha256":"39ab87666877d64ef3a40aa60fbe0c3b6a6acd7001b78fe60e2c76bb6cfc4a94","url":"https://storage.googleapis.com/grok-build-public-artifacts/cli/grok-1.0.34-linux-aarch64"},"x64":{"bytes":163035648,"sha256":"be5905e107d2b8b5f3c142d21ecfe4c8fd32a913d2fd551b788707930c4dc80d","url":"https://storage.googleapis.com/grok-build-public-artifacts/cli/grok-1.0.34-linux-x86_64"}},"grokCliBuild":"3736acbc8658","grokCliVersion":"1.0.34","grokExecutablePath":"/usr/local/bin/grok","identities":{"brokerUid":2100,"firstWorkerUid":2200,"organizationUid":2000},"inferenceGrants":{"bodyMembers":["messages","model","reasoning_effort","response_format","stream","stream_options"],"client":{"configSha256":{"grok-4.5":{"high":"ffbc33728b821e9854fbc7c93601e599225da421ecfd6ebf10d314afcc28d6f2","low":"a07f7436f1268bb399ec233c65d3b3d8fb99a11a1f175f8da1ca133c9367bc74","medium":"1f4c0d4dad1f3b09419b5739db6423a09e0049abc091594c64123a75dd53dfb9"},"grok-4.6":{"high":"5652656effa82f0c4f09cf8226b16e6140332a5a358b571194bb5563312367ac","low":"79314d039f787e4ebfec7dacf57adc969086b948f564dec008f0ed6367e6062f","medium":"6f538de0547c0c4e6a3f04ae08595ceadadabb06b75f6b6ee4c428744bb95cd8"},"grok-build":{"high":"98d16f2b7d12f4eb540d625c853e51d227933e204923e43e8b9b4176f10aca2c","low":"ca15c6a562a008227d39c51d3a3a83715663089b3784e8b46debb1fb67b3c4a1","medium":"01783fb6beadcf6f8486fff0836820ad43fab5662b812a907fe9cfdb83e9804d"}},"envKey":"DAIMON_INFERENCE_GRANT","modelId":"daimon-inference-grok"},"failureCodes":["auth_stale","grant_limit","invalid_request","unavailable"],"ledgerDedupeKey":["grant","request"],"ledgerVersion":"noopolis.daimon.inference-usage.v1","limits":{"maxRequests":64,"maxTokens":2000000},"maxInFlightRequestsPerGrant":1,"maxLiveGrants":8,"messageRoles":["system","user","assistant"],"purposes":["judge","optimizer"],"requestKinds":["request_inference_grant","release_inference_grant"],"tokenPrefix":"inference_","ttlMs":600000},"launcherSocketPath":"/run/daimon-engine-broker/launcher.sock","mcpFacade":{"host":"127.0.0.1","path":"/mcp","port":43124},"nativeAbiVersion":2,"nativeExecutablePath":"/opt/daimon/bin/daimon-engine-broker","projectionVersion":"noopolis.daimon.grok-broker-projection.v2","providerProxy":{"host":"127.0.0.1","port":43123},"registrationPath":"/etc/daimon-engine-broker/registrations.bin","serviceConfigPath":"/etc/daimon-engine-broker/service.json","serviceConfigVersions":["noopolis.daimon.engine-broker-service.v1","noopolis.daimon.engine-broker-service.v2"],"slotPreflightVersion":"noopolis.daimon.grok-slot-preflight.v2","turnLimits":{"bounds":{"maxRequests":[1,48],"maxTokens":[1,10000000],"timeoutMs":[1000,3600000]},"keys":["maxRequests","maxTokens","timeoutMs"],"limitReasons":["tokens","requests","timeout","none"],"maxInFlightRequests":1,"missingUsageEstimate":{"inputBytesPerToken":2,"outputTokens":4096},"requestUsageMaxTokens":500000,"tokenCeilingOvershoot":"at-most-one-request","v1Defaults":{"maxRequests":32,"maxTokens":300000,"timeoutMs":240000},"wakeMayOnlyLower":true},"turnRecordVersions":["noopolis.daimon.engine-broker-turn.v1","noopolis.daimon.engine-broker-turn.v2"],"turnStorePath":"/var/lib/spawnfile/daimon/grok-subscription-realm/turns","wakeLimitEnvironment":{"maxTokens":"DAIMON_ENGINE_WAKE_TOKEN_CEILING","timeoutMs":"DAIMON_ENGINE_WAKE_TIMEOUT_MS"},"worker":{"clockedConfigSha256":{"grok-4.5":{"high":"bf9d36286ec81fd5d37a726c5835d11349e8cc91bafe1428fef7171a3c509091","low":"831a68af36566ae24c1a8301b3fa866167fd173bf22334c2caf897c6d41c8a15","medium":"f517dfefb0b6ee6252d20bd81c057afca6d6f382c92c686d0b83e91a4a77ac07"},"grok-4.6":{"high":"7aac64db19a2f59967bbbe1ed7a8b748985927f0f15cad847eb8cd20535eb70b","low":"634ec9ce49c9e8d508b574e7b183678330359cfed63738df209a38963c730dab","medium":"dacd7e1c2de68d9edd2250b436d76e67046d071f163d56b7c291a315f2342e8c"},"grok-build":{"high":"127e592c0df2c43de51559ae7c90e607f59e2b1ccf5b8c0a082cd64e46ab7823","low":"22adfcb78a281e768f878996de8665e56ff698bf9c46bf06ce894ef9c21c2512","medium":"74c5362427c371e06bbf4ea5f3e10024fdb8ea468103b2206f7f4e873572932a"}},"configSha256":{"grok-4.5":{"high":"c63c3387ce92d94ec3f690abfe98942afcd7c9e17ff84816bbe751f340ab251f","low":"8247127c3625ff7c5d8d527a53596b89ec6557a821ac46cfd00bd122b90daff6","medium":"59288cee61297bb8c002097061a48f77b09d310754187a253ee089f7172a9155"},"grok-4.6":{"high":"65b0212564fb74042b1503d293fb8d3620276033264c0efade2a539ca09218e3","low":"ab58499ac32678097c146479896f2b8a8e2b0e39aea22dc0a60b6227e370538e","medium":"df1a5cc84346e7f6bf6090492fbd19faaefb42953e3bb2e8c6cbc0572242403f"},"grok-build":{"high":"a23724e00d670caee185ba7690d2daa868173e53905cf446f5329666f01ab4e3","low":"fb343f2809903f26d21681470943235031f946e99085542fd89555eb7782cbb5","medium":"8a587ef75c90eab70d19b24583e60051d6fba9d90c558839fdbb15588b4cc656"}},"defaultModel":"grok-4.6","defaultReasoningEffort":"low","home":{"directory":{"group":"worker","mode":1017,"uid":0},"organizationRuntimeHome":{"group":"worker","mode":456,"owner":"organization"},"privateTmp":{"mode":448,"owner":"worker","relativeToWorkerHome":"tmp"},"readOnlyFiles":{"gid":0,"mode":292,"names":["config.toml","managed_config.toml","requirements.toml","sandbox.toml","trusted_folders.toml"],"uid":0},"sandboxEvents":{"group":"broker","mode":416,"owner":"worker","relativePath":"sessions/sandbox-events.jsonl"},"sessionsDirectory":{"group":"worker","mode":1017,"relativePath":"sessions","uid":0},"sharedTmp":{"maxGroupExclusive":2200,"mode":1020,"otherMode":4,"paths":["/tmp","/var/tmp"],"uid":0},"spillDirectory":{"fileMode":416,"group":"worker","mode":1512,"owner":"organization","relativeToRuntimeHome":"tool-output"}},"maxTurns":48,"modelId":"daimon-broker-grok","models":["grok-4.6","grok-4.5","grok-build"],"reasoningEfforts":["low","medium","high"],"systemPromptSha256":"2c31c0085a54a4efbf9c0cf0b8124c56e47f38691b7f0c7fa233a74abaa8ddf8","taskClock":{"directoryMode":493,"directoryPath":"/run/daimon-engine-broker/task-clock","fileMode":420,"maxRegistrations":1,"ownerUid":2100,"preloadPath":"/usr/local/lib/daimon/libfaketime.so.1","timestampFilePath":"/run/daimon-engine-broker/task-clock/faketime"},"toolIds":["run_terminal_cmd","read_file","grep","list_dir","search_tool","use_tool"],"visibleTools":["grep","list_dir","read_file","run_terminal_command","search_tool","use_tool"]}},"grokSubscriptionRealm":{"agentCredentialRelativePath":".grok/auth.json","bootstrapMountPath":"/var/lib/spawnfile/daimon/grok-bootstrap-auth","bootstrapSourceSlot":"grok-auth","directoryMode":448,"durableMountPath":"/var/lib/spawnfile/daimon/grok-subscription-realm","fileMode":384,"maxCredentialBytes":65536},"healthResponseSchema":{"additionalProperties":false,"properties":{"agents":{"items":{"additionalProperties":false,"properties":{"agentId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"state":{"enum":["starting","running","stopping","stopped","idle","failed"]}},"required":["agentId","state"],"type":"object"},"maxItems":32,"type":"array"},"state":{"enum":["starting","running","stopping","stopped"]},"version":{"const":"noopolis.daimon.organization-runtime-health.v1"}},"required":["version","state","agents"],"type":"object"},"operatorDrain":{"authorization":"control-bearer","blockedReason":"operator_stop","drainRoute":"POST /v2/drain","queuedWakes":"retained","response":"workAvailabilityResponseSchema","resumeClearsOperatorStop":false,"resumeRoute":"POST /v2/resume","runningTurns":"finish","scope":"process"},"organizationRuntimeConfigSchema":{"$id":"noopolis.daimon.organization-runtime.v1","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"agents":{"items":{"additionalProperties":false,"properties":{"attention":{"additionalProperties":false,"properties":{"maxBatchBytes":{"maximum":12000,"minimum":1024,"type":"integer"},"maxBatchMessages":{"maximum":32,"minimum":1,"type":"integer"},"maxExecutions":{"maximum":9007199254740991,"minimum":1,"type":"integer"},"maxTokens":{"maximum":9007199254740991,"minimum":1,"type":"integer"}},"type":"object"},"engine":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"grok"}}},"then":{"dependentRequired":{"model":["reasoningEffort"],"reasoningEffort":["model"]},"properties":{"codexSandbox":false,"model":{"enum":["grok-4.6","grok-4.5","grok-build"]},"reasoningEffort":{"enum":["low","medium","high"]}}}},{"if":{"properties":{"kind":{"const":"agy"}}},"then":{"properties":{"codexSandbox":false,"model":false,"reasoningEffort":false}}}],"properties":{"codexSandbox":{"additionalProperties":false,"properties":{"mode":{"const":"workspace-write"},"networkAccess":{"const":false},"webSearch":{"const":"disabled"}},"required":["mode","networkAccess","webSearch"],"type":"object"},"kind":{"enum":["codex","grok","agy"]},"model":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"reasoningEffort":{"enum":["none","minimal","low","medium","high","xhigh","max","ultra","persistent"]}},"required":["kind"],"type":"object"},"id":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"instructions":{"maxLength":16384,"minLength":1,"pattern":"\\S","type":"string"},"mcp":{"items":{"additionalProperties":false,"properties":{"args":{"items":{"maxLength":4096,"type":"string"},"maxItems":32,"type":"array"},"authSecretEnv":{"pattern":"^[A-Za-z_][A-Za-z0-9_]*$","type":"string"},"command":{"pattern":"^/","type":"string"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"name":{"maxLength":4096,"minLength":1,"type":"string"},"tools":{"items":{"maxLength":4096,"minLength":1,"type":"string"},"maxItems":32,"minItems":1,"type":"array","uniqueItems":true},"transport":{"enum":["stdio","sse","streamable_http"]},"url":{"type":"string"}},"required":["name","transport","args","env","tools"],"type":"object"},"maxItems":8,"type":"array"},"memory":{"additionalProperties":false,"properties":{"runtimeHomePath":{"maxLength":4096,"pattern":"^/","type":"string"},"source":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"tokenBudget":{"maximum":1000000,"minimum":1,"type":"integer"}},"required":["runtimeHomePath"],"type":"object"},"moltnet":{"additionalProperties":false,"properties":{"cliPath":{"pattern":"^/","type":"string"},"configPath":{"pattern":"^/","type":"string"},"networks":{"items":{"additionalProperties":false,"properties":{"dms":{"type":"boolean"},"id":{"minLength":1,"type":"string"},"rooms":{"items":{"minLength":1,"type":"string"},"type":"array","uniqueItems":true}},"required":["id","rooms","dms"],"type":"object"},"maxItems":16,"type":"array"}},"required":["cliPath","configPath","networks"],"type":"object"},"name":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"recording":{"additionalProperties":false,"properties":{"directory":{"maxLength":4096,"pattern":"^/[^\\u0000]*$","type":"string"},"keepMs":{"maximum":9007199254740991,"minimum":1,"type":"integer"},"snapshots":{"items":{"additionalProperties":false,"properties":{"id":{"pattern":"^[A-Za-z0-9._-]{1,128}$","type":"string"},"path":{"maxLength":4096,"pattern":"^/[^\\u0000]*$","type":"string"}},"required":["id","path"],"type":"object"},"maxItems":16,"type":"array","uniqueItems":true}},"required":["directory","keepMs","snapshots"],"type":"object"},"runtimeHomePath":{"maxLength":4096,"pattern":"^/","type":"string"},"workspacePath":{"maxLength":4096,"pattern":"^/","type":"string"}},"required":["id","name","instructions","workspacePath","runtimeHomePath","engine"],"type":"object"},"maxItems":32,"minItems":1,"type":"array"},"host":{"additionalProperties":false,"properties":{"bindHost":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"controlTokenEnv":{"maxLength":4096,"pattern":"^[A-Za-z_][A-Za-z0-9_]*$","type":"string"},"port":{"maximum":65535,"minimum":1,"type":"integer"}},"required":["bindHost","port","controlTokenEnv"],"type":"object"},"version":{"const":"noopolis.daimon.organization-runtime.v1"}},"required":["version","host","agents"],"type":"object"},"organizationRuntimeConfigV2Schema":{"$id":"noopolis.daimon.organization-runtime.v2","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"agents":{"items":{"additionalProperties":false,"properties":{"attention":{"additionalProperties":false,"properties":{"maxBatchBytes":{"maximum":12000,"minimum":1024,"type":"integer"},"maxBatchMessages":{"maximum":32,"minimum":1,"type":"integer"},"maxExecutions":{"maximum":9007199254740991,"minimum":1,"type":"integer"},"maxTokens":{"maximum":9007199254740991,"minimum":1,"type":"integer"}},"type":"object"},"engine":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"grok"}}},"then":{"dependentRequired":{"model":["reasoningEffort"],"reasoningEffort":["model"]},"properties":{"codexSandbox":false,"model":{"enum":["grok-4.6","grok-4.5","grok-build"]},"reasoningEffort":{"enum":["low","medium","high"]}}}},{"if":{"properties":{"kind":{"const":"agy"}}},"then":{"properties":{"codexSandbox":false,"model":false,"reasoningEffort":false}}}],"properties":{"codexSandbox":{"additionalProperties":false,"properties":{"mode":{"const":"workspace-write"},"networkAccess":{"const":false},"webSearch":{"const":"disabled"}},"required":["mode","networkAccess","webSearch"],"type":"object"},"kind":{"enum":["codex","grok","agy"]},"model":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"reasoningEffort":{"enum":["none","minimal","low","medium","high","xhigh","max","ultra","persistent"]}},"required":["kind"],"type":"object"},"id":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"instructions":{"maxLength":16384,"minLength":1,"pattern":"\\S","type":"string"},"mcp":{"items":{"additionalProperties":false,"properties":{"args":{"items":{"maxLength":4096,"type":"string"},"maxItems":32,"type":"array"},"authSecretEnv":{"pattern":"^[A-Za-z_][A-Za-z0-9_]*$","type":"string"},"command":{"pattern":"^/","type":"string"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"name":{"maxLength":4096,"minLength":1,"type":"string"},"tools":{"items":{"maxLength":4096,"minLength":1,"type":"string"},"maxItems":32,"minItems":1,"type":"array","uniqueItems":true},"transport":{"enum":["stdio","sse","streamable_http"]},"url":{"type":"string"}},"required":["name","transport","args","env","tools"],"type":"object"},"maxItems":8,"type":"array"},"memory":{"additionalProperties":false,"properties":{"runtimeHomePath":{"maxLength":4096,"pattern":"^/","type":"string"},"source":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"tokenBudget":{"maximum":1000000,"minimum":1,"type":"integer"}},"required":["runtimeHomePath"],"type":"object"},"moltnet":{"additionalProperties":false,"properties":{"cliPath":{"pattern":"^/","type":"string"},"configPath":{"pattern":"^/","type":"string"},"networks":{"items":{"additionalProperties":false,"properties":{"dms":{"type":"boolean"},"id":{"minLength":1,"type":"string"},"rooms":{"items":{"minLength":1,"type":"string"},"type":"array","uniqueItems":true}},"required":["id","rooms","dms"],"type":"object"},"maxItems":16,"type":"array"}},"required":["cliPath","configPath","networks"],"type":"object"},"name":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"recording":{"additionalProperties":false,"properties":{"directory":{"maxLength":4096,"pattern":"^/[^\\u0000]*$","type":"string"},"keepMs":{"maximum":9007199254740991,"minimum":1,"type":"integer"},"snapshots":{"items":{"additionalProperties":false,"properties":{"id":{"pattern":"^[A-Za-z0-9._-]{1,128}$","type":"string"},"path":{"maxLength":4096,"pattern":"^/[^\\u0000]*$","type":"string"}},"required":["id","path"],"type":"object"},"maxItems":16,"type":"array","uniqueItems":true}},"required":["directory","keepMs","snapshots"],"type":"object"},"runtimeHomePath":{"maxLength":4096,"pattern":"^/","type":"string"},"schedule":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"disabled"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"interval_ms":{"maximum":31536000000,"minimum":1,"type":"integer"},"jitter_seconds":{"maximum":3600,"minimum":0,"type":"integer"},"kind":{"const":"every"},"prompt":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"}},"required":["kind","interval_ms","prompt"],"type":"object"},{"additionalProperties":false,"properties":{"cron":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"jitter_seconds":{"maximum":3600,"minimum":0,"type":"integer"},"kind":{"const":"cron"},"prompt":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"timezone":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"}},"required":["kind","cron","timezone","prompt"],"type":"object"}]},"workspacePath":{"maxLength":4096,"pattern":"^/","type":"string"}},"required":["id","name","instructions","workspacePath","runtimeHomePath","engine","schedule"],"type":"object"},"maxItems":32,"minItems":1,"type":"array"},"host":{"additionalProperties":false,"properties":{"bindHost":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"controlTokenEnv":{"maxLength":4096,"pattern":"^[A-Za-z_][A-Za-z0-9_]*$","type":"string"},"port":{"maximum":65535,"minimum":1,"type":"integer"}},"required":["bindHost","port","controlTokenEnv"],"type":"object"},"version":{"const":"noopolis.daimon.organization-runtime.v2"}},"required":["version","host","agents"],"type":"object"},"supportedEngineKinds":["agy","codex","grok"],"version":"noopolis.daimon.runtime-contract-manifest.v4","wakeAcceptanceTypes":["manual","message","schedule","external"],"wakeMoments":{"captureDeadlineMs":60000,"completionSuffix":".complete","configField":"agents[].recording","finalizationDeadlineMs":1000,"maxManifestBytes":134217728,"maxRowBytes":4194304,"maxSnapshotEntries":200000,"maxSnapshotRoots":16,"rowsFile":"wake-moments.jsonl","snapshotsDirectory":"snapshots","version":"noopolis.daimon.wake-moment.v1"},"wakeRequestSchema":{"additionalProperties":false,"properties":{"agentId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"event":{"additionalProperties":false,"properties":{"id":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"kind":{"enum":["manual","message","schedule","external"]},"occurredAt":{"format":"date-time","pattern":"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$","type":"string"},"text":{"maxLength":4096,"type":"string"},"version":{"const":"noopolis.daimon.wake.v1"}},"required":["version","id","kind","text","occurredAt"],"type":"object"}},"required":["agentId","event"],"type":"object"},"wakeResultSchema":{"oneOf":[{"additionalProperties":false,"properties":{"agentId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"durationMs":{"minimum":0,"type":"integer"},"status":{"const":"completed"},"text":{"maxLength":4096,"type":"string"},"version":{"const":"noopolis.daimon.wake-result.v1"},"wakeId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"}},"required":["version","status","agentId","wakeId","text","durationMs"],"type":"object"},{"additionalProperties":false,"properties":{"agentId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"code":{"enum":["unauthorized","unknown_agent","queue_full","durable_inbox_required"]},"status":{"const":"rejected"},"version":{"const":"noopolis.daimon.wake-result.v1"},"wakeId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"}},"required":["version","status","agentId","wakeId","code"],"type":"object"},{"additionalProperties":false,"properties":{"agentId":{"maxLength":4096,"type":"string"},"code":{"const":"invalid_request"},"status":{"const":"rejected"},"version":{"const":"noopolis.daimon.wake-result.v1"},"wakeId":{"maxLength":4096,"type":"string"}},"required":["version","status","agentId","wakeId","code"],"type":"object"},{"additionalProperties":false,"properties":{"agentId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"code":{"enum":["host_stopping","host_stopped","queued_wake_stopped","active_wake_aborted"]},"status":{"const":"stopped"},"version":{"const":"noopolis.daimon.wake-result.v1"},"wakeId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"}},"required":["version","status","agentId","wakeId","code"],"type":"object"},{"additionalProperties":false,"properties":{"agentId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"code":{"const":"engine_failed"},"status":{"const":"failed"},"version":{"const":"noopolis.daimon.wake-result.v1"},"wakeId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"}},"required":["version","status","agentId","wakeId","code"],"type":"object"}]},"workAvailabilityResponseSchema":{"additionalProperties":false,"properties":{"agents":{"items":{"additionalProperties":false,"properties":{"agent_id":{"type":"string"},"budget":{"additionalProperties":false,"properties":{"agent_executions_remaining":{"minimum":0,"type":"integer"},"agent_executions_used":{"minimum":0,"type":"integer"},"agent_tokens_remaining":{"minimum":0,"type":"integer"},"agent_tokens_used":{"minimum":0,"type":"integer"},"armed":{"type":"boolean"},"epoch":{"type":"string"},"executions_remaining":{"minimum":0,"type":"integer"},"executions_used":{"minimum":0,"type":"integer"},"reason":{"type":"string"},"state":{"enum":["available","paused","stopped"]},"tokens_remaining":{"minimum":0,"type":"integer"},"tokens_used":{"minimum":0,"type":"integer"}},"required":["armed","epoch","state","executions_used","executions_remaining","tokens_used","tokens_remaining","agent_executions_used","agent_tokens_used"],"type":"object"},"deferred":{"minimum":0,"type":"integer"},"error":{"type":"string"},"pending":{"minimum":0,"type":"integer"},"running":{"type":"boolean"}},"required":["agent_id","pending","running","deferred","budget"],"type":"object"},"maxItems":32,"type":"array"},"drain":{"additionalProperties":false,"properties":{"since":{"type":"string"},"state":{"enum":["draining","drained"]}},"required":["state","since"],"type":"object"},"state":{"enum":["running","paused","stopped"]},"version":{"const":"noopolis.daimon.work-availability.v1"}},"required":["version","state","agents"],"type":"object"},"workBlockedSchema":{"additionalProperties":false,"properties":{"reason":{"enum":["operator_stop","ledger_unavailable","host_stopping","host_stopped","queue_full"]},"retry_after_ms":{"maximum":300000,"minimum":1000,"type":"integer"},"version":{"const":"noopolis.daimon.work-blocked.v1"}},"required":["version","reason","retry_after_ms"],"type":"object"}} diff --git a/src/runtime/daimon/contract-manifest.sha256 b/src/runtime/daimon/contract-manifest.sha256 index 28e6e8b3..21b41675 100644 --- a/src/runtime/daimon/contract-manifest.sha256 +++ b/src/runtime/daimon/contract-manifest.sha256 @@ -1 +1 @@ -sha256:f7bdd283c420cb6685d32fb556bfb1dcf48f756b3a5ca5ae61e96950152a5cc0 +sha256:e7b457b2ca201cc686e66e841bb395780187dbd1c349b22be8973f0b346c6b4a diff --git a/src/runtime/daimon/contractManifest.test.ts b/src/runtime/daimon/contractManifest.test.ts index 14194439..137e6c12 100644 --- a/src/runtime/daimon/contractManifest.test.ts +++ b/src/runtime/daimon/contractManifest.test.ts @@ -184,7 +184,7 @@ describe("Daimon contract manifest", () => { const vendoredRoot = path.dirname(fileURLToPath(import.meta.url)); const vendored = await readVerifiedDaimonContractManifest(vendoredRoot); expect(vendored.digest).toBe(DAIMON_CONTRACT_MANIFEST_SHA256); - expect(daimonSupportsWakeMoments(vendored.manifest)).toBe(false); + expect(daimonSupportsWakeMoments(vendored.manifest)).toBe(true); const bytes = await readFile(path.join(vendoredRoot, DAIMON_CONTRACT_MANIFEST_FILE), "utf8"); const raw = JSON.parse(bytes) as { consumedConfigFields: string[] }; expect(raw.consumedConfigFields).toContain("agents[].memory"); diff --git a/src/runtime/daimon/contractManifest.ts b/src/runtime/daimon/contractManifest.ts index 0c3501a6..cc0228b6 100644 --- a/src/runtime/daimon/contractManifest.ts +++ b/src/runtime/daimon/contractManifest.ts @@ -6,9 +6,9 @@ import { SpawnfileError } from "../../shared/index.js"; import { DAIMON_WAKE_MOMENTS, isDaimonWakeMoments } from "./wakeMoments.js"; export const DAIMON_CONTRACT_MANIFEST_VERSION = - "noopolis.daimon.runtime-contract-manifest.v3" as const; + "noopolis.daimon.runtime-contract-manifest.v4" as const; export const DAIMON_CONTRACT_MANIFEST_SHA256 = - "sha256:f7bdd283c420cb6685d32fb556bfb1dcf48f756b3a5ca5ae61e96950152a5cc0" as const; + "sha256:e7b457b2ca201cc686e66e841bb395780187dbd1c349b22be8973f0b346c6b4a" as const; export const DAIMON_CONTRACT_MANIFEST_FILE = "contract-manifest.json"; export const DAIMON_CONTRACT_MANIFEST_DIGEST_FILE = "contract-manifest.sha256"; export const DAIMON_RUNTIME_HOME_ROOT = "/var/lib/spawnfile/instances/daimon"; @@ -76,6 +76,20 @@ export const DAIMON_GROK_ENGINE_BROKER = { "grok-4.5": { low: "8247127c3625ff7c5d8d527a53596b89ec6557a821ac46cfd00bd122b90daff6", medium: "59288cee61297bb8c002097061a48f77b09d310754187a253ee089f7172a9155", high: "c63c3387ce92d94ec3f690abfe98942afcd7c9e17ff84816bbe751f340ab251f" }, "grok-build": { low: "fb343f2809903f26d21681470943235031f946e99085542fd89555eb7782cbb5", medium: "8a587ef75c90eab70d19b24583e60051d6fba9d90c558839fdbb15588b4cc656", high: "a23724e00d670caee185ba7690d2daa868173e53905cf446f5329666f01ab4e3" } }, + clockedConfigSha256: { + "grok-4.6": { low: "634ec9ce49c9e8d508b574e7b183678330359cfed63738df209a38963c730dab", medium: "dacd7e1c2de68d9edd2250b436d76e67046d071f163d56b7c291a315f2342e8c", high: "7aac64db19a2f59967bbbe1ed7a8b748985927f0f15cad847eb8cd20535eb70b" }, + "grok-4.5": { low: "831a68af36566ae24c1a8301b3fa866167fd173bf22334c2caf897c6d41c8a15", medium: "f517dfefb0b6ee6252d20bd81c057afca6d6f382c92c686d0b83e91a4a77ac07", high: "bf9d36286ec81fd5d37a726c5835d11349e8cc91bafe1428fef7171a3c509091" }, + "grok-build": { low: "22adfcb78a281e768f878996de8665e56ff698bf9c46bf06ce894ef9c21c2512", medium: "74c5362427c371e06bbf4ea5f3e10024fdb8ea468103b2206f7f4e873572932a", high: "127e592c0df2c43de51559ae7c90e607f59e2b1ccf5b8c0a082cd64e46ab7823" } + }, + taskClock: { + preloadPath: "/usr/local/lib/daimon/libfaketime.so.1", + timestampFilePath: "/run/daimon-engine-broker/task-clock/faketime", + directoryPath: "/run/daimon-engine-broker/task-clock", + directoryMode: 0o755, + fileMode: 0o644, + ownerUid: 2100, + maxRegistrations: 1 + }, home: { directory: { uid: 0, group: "worker", mode: 0o1771 }, sessionsDirectory: { relativePath: "sessions", uid: 0, group: "worker", mode: 0o1771 }, @@ -88,7 +102,7 @@ export const DAIMON_GROK_ENGINE_BROKER = { } }, bounds: { promptBytes: 65_536, capabilityBytes: 4_096, capabilityBundleBytes: 8_196, outputBytes: 262_144 }, - controlProtocolVersion: "noopolis.daimon.engine-broker.v2", + controlProtocolVersion: "noopolis.daimon.engine-broker.v3", turnRecordVersions: ["noopolis.daimon.engine-broker-turn.v1", "noopolis.daimon.engine-broker-turn.v2"], serviceConfigVersions: ["noopolis.daimon.engine-broker-service.v1", "noopolis.daimon.engine-broker-service.v2"], turnLimits: { @@ -103,7 +117,7 @@ export const DAIMON_GROK_ENGINE_BROKER = { missingUsageEstimate: { inputBytesPerToken: 2, outputTokens: 4_096 } }, wakeLimitEnvironment: { timeoutMs: "DAIMON_ENGINE_WAKE_TIMEOUT_MS", maxTokens: "DAIMON_ENGINE_WAKE_TOKEN_CEILING" }, - projectionVersion: "noopolis.daimon.grok-broker-projection.v1", + projectionVersion: "noopolis.daimon.grok-broker-projection.v2", slotPreflightVersion: "noopolis.daimon.grok-slot-preflight.v2", inferenceGrants: { requestKinds: ["request_inference_grant", "release_inference_grant"], diff --git a/src/runtime/daimon/grokWorkerConfigBytes.ts b/src/runtime/daimon/grokWorkerConfigBytes.ts index c6d448a5..92289d4c 100644 --- a/src/runtime/daimon/grokWorkerConfigBytes.ts +++ b/src/runtime/daimon/grokWorkerConfigBytes.ts @@ -20,6 +20,25 @@ export const DAIMON_GROK_WORKER_CONFIG_BYTES = { } } as const; +/** Daimon's additional clocked variant; production keeps the default bytes above. */ +export const DAIMON_GROK_CLOCKED_WORKER_CONFIG_BYTES = { + "grok-4.6": { + "low": "[cli]\nauto_update = false\nuse_leader = false\nshow_tips = false\n\n[features]\ntelemetry = false\ntitle_refresh = false\nsession_recap = false\nturn_summary = false\nrepo_status_in_system_prompt = false\ncodebase_indexing = false\nbackend_tools = false\nask_user_question = false\nimage_gen = false\nvideo_gen = false\nweb_fetch = false\ncampaigns = false\nmanaged_config = false\n\n[managed_mcps]\nenabled = false\n\n[skills]\ndisabled = [\"build-with-ai\", \"code-review\", \"create-skill\", \"create-workflow\", \"design\", \"docx\", \"execute-plan\", \"game-animation-frames\", \"game-asset-core\", \"game-character-consistency\", \"game-tilesets\", \"game-ui-icons\", \"imagine\", \"implement\", \"learn\", \"long-running-background-tasks\", \"pdf\", \"pptx\", \"pr-babysit\", \"resume-claude\", \"resume-codex\", \"resume-cursor\", \"review\", \"skill-design-principles\", \"statusline\"]\n\n[workflows]\nenabled = false\n\n[models]\ndefault = \"daimon-broker-grok\"\ndefault_reasoning_effort = \"low\"\nsession_summary = \"daimon-session-title-disabled\"\n\n[model.daimon-session-title-disabled]\nmodel = \"disabled\"\nbase_url = \"http://127.0.0.1:43123/v1\"\napi_key = \"session-title-disabled\"\nmax_retries = 0\nhidden = true\n\n[model.daimon-broker-grok]\nmodel = \"grok-4.6\"\nbase_url = \"http://127.0.0.1:43123/v1\"\nenv_key = \"DAIMON_PROVIDER_CAPABILITY\"\napi_backend = \"chat_completions\"\ncontext_window = 131072\nsupports_backend_search = false\nmax_retries = 0\n\n[[model.daimon-broker-grok.reasoning_efforts]]\nvalue = \"low\"\nlabel = \"Low\"\ndefault = true\n\n[mcp_servers.daimon]\nurl = \"http://127.0.0.1:43124/mcp\"\nbearer_token_env_var = \"DAIMON_MCP_CAPABILITY\"\n\n[shell_environment_policy]\ninherit=\"all\"\nignore_default_excludes=false\nexclude=[]\ninclude_only=[]\nset={\"LD_PRELOAD\"=\"/usr/local/lib/daimon/libfaketime.so.1\",\"FAKETIME_TIMESTAMP_FILE\"=\"/run/daimon-engine-broker/task-clock/faketime\",\"FAKETIME_DONT_FAKE_MONOTONIC\"=\"1\",\"LC_ALL\"=\"C\"}\n", + "medium": "[cli]\nauto_update = false\nuse_leader = false\nshow_tips = false\n\n[features]\ntelemetry = false\ntitle_refresh = false\nsession_recap = false\nturn_summary = false\nrepo_status_in_system_prompt = false\ncodebase_indexing = false\nbackend_tools = false\nask_user_question = false\nimage_gen = false\nvideo_gen = false\nweb_fetch = false\ncampaigns = false\nmanaged_config = false\n\n[managed_mcps]\nenabled = false\n\n[skills]\ndisabled = [\"build-with-ai\", \"code-review\", \"create-skill\", \"create-workflow\", \"design\", \"docx\", \"execute-plan\", \"game-animation-frames\", \"game-asset-core\", \"game-character-consistency\", \"game-tilesets\", \"game-ui-icons\", \"imagine\", \"implement\", \"learn\", \"long-running-background-tasks\", \"pdf\", \"pptx\", \"pr-babysit\", \"resume-claude\", \"resume-codex\", \"resume-cursor\", \"review\", \"skill-design-principles\", \"statusline\"]\n\n[workflows]\nenabled = false\n\n[models]\ndefault = \"daimon-broker-grok\"\ndefault_reasoning_effort = \"medium\"\nsession_summary = \"daimon-session-title-disabled\"\n\n[model.daimon-session-title-disabled]\nmodel = \"disabled\"\nbase_url = \"http://127.0.0.1:43123/v1\"\napi_key = \"session-title-disabled\"\nmax_retries = 0\nhidden = true\n\n[model.daimon-broker-grok]\nmodel = \"grok-4.6\"\nbase_url = \"http://127.0.0.1:43123/v1\"\nenv_key = \"DAIMON_PROVIDER_CAPABILITY\"\napi_backend = \"chat_completions\"\ncontext_window = 131072\nsupports_backend_search = false\nmax_retries = 0\n\n[[model.daimon-broker-grok.reasoning_efforts]]\nvalue = \"medium\"\nlabel = \"Medium\"\ndefault = true\n\n[mcp_servers.daimon]\nurl = \"http://127.0.0.1:43124/mcp\"\nbearer_token_env_var = \"DAIMON_MCP_CAPABILITY\"\n\n[shell_environment_policy]\ninherit=\"all\"\nignore_default_excludes=false\nexclude=[]\ninclude_only=[]\nset={\"LD_PRELOAD\"=\"/usr/local/lib/daimon/libfaketime.so.1\",\"FAKETIME_TIMESTAMP_FILE\"=\"/run/daimon-engine-broker/task-clock/faketime\",\"FAKETIME_DONT_FAKE_MONOTONIC\"=\"1\",\"LC_ALL\"=\"C\"}\n", + "high": "[cli]\nauto_update = false\nuse_leader = false\nshow_tips = false\n\n[features]\ntelemetry = false\ntitle_refresh = false\nsession_recap = false\nturn_summary = false\nrepo_status_in_system_prompt = false\ncodebase_indexing = false\nbackend_tools = false\nask_user_question = false\nimage_gen = false\nvideo_gen = false\nweb_fetch = false\ncampaigns = false\nmanaged_config = false\n\n[managed_mcps]\nenabled = false\n\n[skills]\ndisabled = [\"build-with-ai\", \"code-review\", \"create-skill\", \"create-workflow\", \"design\", \"docx\", \"execute-plan\", \"game-animation-frames\", \"game-asset-core\", \"game-character-consistency\", \"game-tilesets\", \"game-ui-icons\", \"imagine\", \"implement\", \"learn\", \"long-running-background-tasks\", \"pdf\", \"pptx\", \"pr-babysit\", \"resume-claude\", \"resume-codex\", \"resume-cursor\", \"review\", \"skill-design-principles\", \"statusline\"]\n\n[workflows]\nenabled = false\n\n[models]\ndefault = \"daimon-broker-grok\"\ndefault_reasoning_effort = \"high\"\nsession_summary = \"daimon-session-title-disabled\"\n\n[model.daimon-session-title-disabled]\nmodel = \"disabled\"\nbase_url = \"http://127.0.0.1:43123/v1\"\napi_key = \"session-title-disabled\"\nmax_retries = 0\nhidden = true\n\n[model.daimon-broker-grok]\nmodel = \"grok-4.6\"\nbase_url = \"http://127.0.0.1:43123/v1\"\nenv_key = \"DAIMON_PROVIDER_CAPABILITY\"\napi_backend = \"chat_completions\"\ncontext_window = 131072\nsupports_backend_search = false\nmax_retries = 0\n\n[[model.daimon-broker-grok.reasoning_efforts]]\nvalue = \"high\"\nlabel = \"High\"\ndefault = true\n\n[mcp_servers.daimon]\nurl = \"http://127.0.0.1:43124/mcp\"\nbearer_token_env_var = \"DAIMON_MCP_CAPABILITY\"\n\n[shell_environment_policy]\ninherit=\"all\"\nignore_default_excludes=false\nexclude=[]\ninclude_only=[]\nset={\"LD_PRELOAD\"=\"/usr/local/lib/daimon/libfaketime.so.1\",\"FAKETIME_TIMESTAMP_FILE\"=\"/run/daimon-engine-broker/task-clock/faketime\",\"FAKETIME_DONT_FAKE_MONOTONIC\"=\"1\",\"LC_ALL\"=\"C\"}\n" + }, + "grok-4.5": { + "low": "[cli]\nauto_update = false\nuse_leader = false\nshow_tips = false\n\n[features]\ntelemetry = false\ntitle_refresh = false\nsession_recap = false\nturn_summary = false\nrepo_status_in_system_prompt = false\ncodebase_indexing = false\nbackend_tools = false\nask_user_question = false\nimage_gen = false\nvideo_gen = false\nweb_fetch = false\ncampaigns = false\nmanaged_config = false\n\n[managed_mcps]\nenabled = false\n\n[skills]\ndisabled = [\"build-with-ai\", \"code-review\", \"create-skill\", \"create-workflow\", \"design\", \"docx\", \"execute-plan\", \"game-animation-frames\", \"game-asset-core\", \"game-character-consistency\", \"game-tilesets\", \"game-ui-icons\", \"imagine\", \"implement\", \"learn\", \"long-running-background-tasks\", \"pdf\", \"pptx\", \"pr-babysit\", \"resume-claude\", \"resume-codex\", \"resume-cursor\", \"review\", \"skill-design-principles\", \"statusline\"]\n\n[workflows]\nenabled = false\n\n[models]\ndefault = \"daimon-broker-grok\"\ndefault_reasoning_effort = \"low\"\nsession_summary = \"daimon-session-title-disabled\"\n\n[model.daimon-session-title-disabled]\nmodel = \"disabled\"\nbase_url = \"http://127.0.0.1:43123/v1\"\napi_key = \"session-title-disabled\"\nmax_retries = 0\nhidden = true\n\n[model.daimon-broker-grok]\nmodel = \"grok-4.5\"\nbase_url = \"http://127.0.0.1:43123/v1\"\nenv_key = \"DAIMON_PROVIDER_CAPABILITY\"\napi_backend = \"chat_completions\"\ncontext_window = 131072\nsupports_backend_search = false\nmax_retries = 0\n\n[[model.daimon-broker-grok.reasoning_efforts]]\nvalue = \"low\"\nlabel = \"Low\"\ndefault = true\n\n[mcp_servers.daimon]\nurl = \"http://127.0.0.1:43124/mcp\"\nbearer_token_env_var = \"DAIMON_MCP_CAPABILITY\"\n\n[shell_environment_policy]\ninherit=\"all\"\nignore_default_excludes=false\nexclude=[]\ninclude_only=[]\nset={\"LD_PRELOAD\"=\"/usr/local/lib/daimon/libfaketime.so.1\",\"FAKETIME_TIMESTAMP_FILE\"=\"/run/daimon-engine-broker/task-clock/faketime\",\"FAKETIME_DONT_FAKE_MONOTONIC\"=\"1\",\"LC_ALL\"=\"C\"}\n", + "medium": "[cli]\nauto_update = false\nuse_leader = false\nshow_tips = false\n\n[features]\ntelemetry = false\ntitle_refresh = false\nsession_recap = false\nturn_summary = false\nrepo_status_in_system_prompt = false\ncodebase_indexing = false\nbackend_tools = false\nask_user_question = false\nimage_gen = false\nvideo_gen = false\nweb_fetch = false\ncampaigns = false\nmanaged_config = false\n\n[managed_mcps]\nenabled = false\n\n[skills]\ndisabled = [\"build-with-ai\", \"code-review\", \"create-skill\", \"create-workflow\", \"design\", \"docx\", \"execute-plan\", \"game-animation-frames\", \"game-asset-core\", \"game-character-consistency\", \"game-tilesets\", \"game-ui-icons\", \"imagine\", \"implement\", \"learn\", \"long-running-background-tasks\", \"pdf\", \"pptx\", \"pr-babysit\", \"resume-claude\", \"resume-codex\", \"resume-cursor\", \"review\", \"skill-design-principles\", \"statusline\"]\n\n[workflows]\nenabled = false\n\n[models]\ndefault = \"daimon-broker-grok\"\ndefault_reasoning_effort = \"medium\"\nsession_summary = \"daimon-session-title-disabled\"\n\n[model.daimon-session-title-disabled]\nmodel = \"disabled\"\nbase_url = \"http://127.0.0.1:43123/v1\"\napi_key = \"session-title-disabled\"\nmax_retries = 0\nhidden = true\n\n[model.daimon-broker-grok]\nmodel = \"grok-4.5\"\nbase_url = \"http://127.0.0.1:43123/v1\"\nenv_key = \"DAIMON_PROVIDER_CAPABILITY\"\napi_backend = \"chat_completions\"\ncontext_window = 131072\nsupports_backend_search = false\nmax_retries = 0\n\n[[model.daimon-broker-grok.reasoning_efforts]]\nvalue = \"medium\"\nlabel = \"Medium\"\ndefault = true\n\n[mcp_servers.daimon]\nurl = \"http://127.0.0.1:43124/mcp\"\nbearer_token_env_var = \"DAIMON_MCP_CAPABILITY\"\n\n[shell_environment_policy]\ninherit=\"all\"\nignore_default_excludes=false\nexclude=[]\ninclude_only=[]\nset={\"LD_PRELOAD\"=\"/usr/local/lib/daimon/libfaketime.so.1\",\"FAKETIME_TIMESTAMP_FILE\"=\"/run/daimon-engine-broker/task-clock/faketime\",\"FAKETIME_DONT_FAKE_MONOTONIC\"=\"1\",\"LC_ALL\"=\"C\"}\n", + "high": "[cli]\nauto_update = false\nuse_leader = false\nshow_tips = false\n\n[features]\ntelemetry = false\ntitle_refresh = false\nsession_recap = false\nturn_summary = false\nrepo_status_in_system_prompt = false\ncodebase_indexing = false\nbackend_tools = false\nask_user_question = false\nimage_gen = false\nvideo_gen = false\nweb_fetch = false\ncampaigns = false\nmanaged_config = false\n\n[managed_mcps]\nenabled = false\n\n[skills]\ndisabled = [\"build-with-ai\", \"code-review\", \"create-skill\", \"create-workflow\", \"design\", \"docx\", \"execute-plan\", \"game-animation-frames\", \"game-asset-core\", \"game-character-consistency\", \"game-tilesets\", \"game-ui-icons\", \"imagine\", \"implement\", \"learn\", \"long-running-background-tasks\", \"pdf\", \"pptx\", \"pr-babysit\", \"resume-claude\", \"resume-codex\", \"resume-cursor\", \"review\", \"skill-design-principles\", \"statusline\"]\n\n[workflows]\nenabled = false\n\n[models]\ndefault = \"daimon-broker-grok\"\ndefault_reasoning_effort = \"high\"\nsession_summary = \"daimon-session-title-disabled\"\n\n[model.daimon-session-title-disabled]\nmodel = \"disabled\"\nbase_url = \"http://127.0.0.1:43123/v1\"\napi_key = \"session-title-disabled\"\nmax_retries = 0\nhidden = true\n\n[model.daimon-broker-grok]\nmodel = \"grok-4.5\"\nbase_url = \"http://127.0.0.1:43123/v1\"\nenv_key = \"DAIMON_PROVIDER_CAPABILITY\"\napi_backend = \"chat_completions\"\ncontext_window = 131072\nsupports_backend_search = false\nmax_retries = 0\n\n[[model.daimon-broker-grok.reasoning_efforts]]\nvalue = \"high\"\nlabel = \"High\"\ndefault = true\n\n[mcp_servers.daimon]\nurl = \"http://127.0.0.1:43124/mcp\"\nbearer_token_env_var = \"DAIMON_MCP_CAPABILITY\"\n\n[shell_environment_policy]\ninherit=\"all\"\nignore_default_excludes=false\nexclude=[]\ninclude_only=[]\nset={\"LD_PRELOAD\"=\"/usr/local/lib/daimon/libfaketime.so.1\",\"FAKETIME_TIMESTAMP_FILE\"=\"/run/daimon-engine-broker/task-clock/faketime\",\"FAKETIME_DONT_FAKE_MONOTONIC\"=\"1\",\"LC_ALL\"=\"C\"}\n" + }, + "grok-build": { + "low": "[cli]\nauto_update = false\nuse_leader = false\nshow_tips = false\n\n[features]\ntelemetry = false\ntitle_refresh = false\nsession_recap = false\nturn_summary = false\nrepo_status_in_system_prompt = false\ncodebase_indexing = false\nbackend_tools = false\nask_user_question = false\nimage_gen = false\nvideo_gen = false\nweb_fetch = false\ncampaigns = false\nmanaged_config = false\n\n[managed_mcps]\nenabled = false\n\n[skills]\ndisabled = [\"build-with-ai\", \"code-review\", \"create-skill\", \"create-workflow\", \"design\", \"docx\", \"execute-plan\", \"game-animation-frames\", \"game-asset-core\", \"game-character-consistency\", \"game-tilesets\", \"game-ui-icons\", \"imagine\", \"implement\", \"learn\", \"long-running-background-tasks\", \"pdf\", \"pptx\", \"pr-babysit\", \"resume-claude\", \"resume-codex\", \"resume-cursor\", \"review\", \"skill-design-principles\", \"statusline\"]\n\n[workflows]\nenabled = false\n\n[models]\ndefault = \"daimon-broker-grok\"\ndefault_reasoning_effort = \"low\"\nsession_summary = \"daimon-session-title-disabled\"\n\n[model.daimon-session-title-disabled]\nmodel = \"disabled\"\nbase_url = \"http://127.0.0.1:43123/v1\"\napi_key = \"session-title-disabled\"\nmax_retries = 0\nhidden = true\n\n[model.daimon-broker-grok]\nmodel = \"grok-build\"\nbase_url = \"http://127.0.0.1:43123/v1\"\nenv_key = \"DAIMON_PROVIDER_CAPABILITY\"\napi_backend = \"chat_completions\"\ncontext_window = 131072\nsupports_backend_search = false\nmax_retries = 0\n\n[[model.daimon-broker-grok.reasoning_efforts]]\nvalue = \"low\"\nlabel = \"Low\"\ndefault = true\n\n[mcp_servers.daimon]\nurl = \"http://127.0.0.1:43124/mcp\"\nbearer_token_env_var = \"DAIMON_MCP_CAPABILITY\"\n\n[shell_environment_policy]\ninherit=\"all\"\nignore_default_excludes=false\nexclude=[]\ninclude_only=[]\nset={\"LD_PRELOAD\"=\"/usr/local/lib/daimon/libfaketime.so.1\",\"FAKETIME_TIMESTAMP_FILE\"=\"/run/daimon-engine-broker/task-clock/faketime\",\"FAKETIME_DONT_FAKE_MONOTONIC\"=\"1\",\"LC_ALL\"=\"C\"}\n", + "medium": "[cli]\nauto_update = false\nuse_leader = false\nshow_tips = false\n\n[features]\ntelemetry = false\ntitle_refresh = false\nsession_recap = false\nturn_summary = false\nrepo_status_in_system_prompt = false\ncodebase_indexing = false\nbackend_tools = false\nask_user_question = false\nimage_gen = false\nvideo_gen = false\nweb_fetch = false\ncampaigns = false\nmanaged_config = false\n\n[managed_mcps]\nenabled = false\n\n[skills]\ndisabled = [\"build-with-ai\", \"code-review\", \"create-skill\", \"create-workflow\", \"design\", \"docx\", \"execute-plan\", \"game-animation-frames\", \"game-asset-core\", \"game-character-consistency\", \"game-tilesets\", \"game-ui-icons\", \"imagine\", \"implement\", \"learn\", \"long-running-background-tasks\", \"pdf\", \"pptx\", \"pr-babysit\", \"resume-claude\", \"resume-codex\", \"resume-cursor\", \"review\", \"skill-design-principles\", \"statusline\"]\n\n[workflows]\nenabled = false\n\n[models]\ndefault = \"daimon-broker-grok\"\ndefault_reasoning_effort = \"medium\"\nsession_summary = \"daimon-session-title-disabled\"\n\n[model.daimon-session-title-disabled]\nmodel = \"disabled\"\nbase_url = \"http://127.0.0.1:43123/v1\"\napi_key = \"session-title-disabled\"\nmax_retries = 0\nhidden = true\n\n[model.daimon-broker-grok]\nmodel = \"grok-build\"\nbase_url = \"http://127.0.0.1:43123/v1\"\nenv_key = \"DAIMON_PROVIDER_CAPABILITY\"\napi_backend = \"chat_completions\"\ncontext_window = 131072\nsupports_backend_search = false\nmax_retries = 0\n\n[[model.daimon-broker-grok.reasoning_efforts]]\nvalue = \"medium\"\nlabel = \"Medium\"\ndefault = true\n\n[mcp_servers.daimon]\nurl = \"http://127.0.0.1:43124/mcp\"\nbearer_token_env_var = \"DAIMON_MCP_CAPABILITY\"\n\n[shell_environment_policy]\ninherit=\"all\"\nignore_default_excludes=false\nexclude=[]\ninclude_only=[]\nset={\"LD_PRELOAD\"=\"/usr/local/lib/daimon/libfaketime.so.1\",\"FAKETIME_TIMESTAMP_FILE\"=\"/run/daimon-engine-broker/task-clock/faketime\",\"FAKETIME_DONT_FAKE_MONOTONIC\"=\"1\",\"LC_ALL\"=\"C\"}\n", + "high": "[cli]\nauto_update = false\nuse_leader = false\nshow_tips = false\n\n[features]\ntelemetry = false\ntitle_refresh = false\nsession_recap = false\nturn_summary = false\nrepo_status_in_system_prompt = false\ncodebase_indexing = false\nbackend_tools = false\nask_user_question = false\nimage_gen = false\nvideo_gen = false\nweb_fetch = false\ncampaigns = false\nmanaged_config = false\n\n[managed_mcps]\nenabled = false\n\n[skills]\ndisabled = [\"build-with-ai\", \"code-review\", \"create-skill\", \"create-workflow\", \"design\", \"docx\", \"execute-plan\", \"game-animation-frames\", \"game-asset-core\", \"game-character-consistency\", \"game-tilesets\", \"game-ui-icons\", \"imagine\", \"implement\", \"learn\", \"long-running-background-tasks\", \"pdf\", \"pptx\", \"pr-babysit\", \"resume-claude\", \"resume-codex\", \"resume-cursor\", \"review\", \"skill-design-principles\", \"statusline\"]\n\n[workflows]\nenabled = false\n\n[models]\ndefault = \"daimon-broker-grok\"\ndefault_reasoning_effort = \"high\"\nsession_summary = \"daimon-session-title-disabled\"\n\n[model.daimon-session-title-disabled]\nmodel = \"disabled\"\nbase_url = \"http://127.0.0.1:43123/v1\"\napi_key = \"session-title-disabled\"\nmax_retries = 0\nhidden = true\n\n[model.daimon-broker-grok]\nmodel = \"grok-build\"\nbase_url = \"http://127.0.0.1:43123/v1\"\nenv_key = \"DAIMON_PROVIDER_CAPABILITY\"\napi_backend = \"chat_completions\"\ncontext_window = 131072\nsupports_backend_search = false\nmax_retries = 0\n\n[[model.daimon-broker-grok.reasoning_efforts]]\nvalue = \"high\"\nlabel = \"High\"\ndefault = true\n\n[mcp_servers.daimon]\nurl = \"http://127.0.0.1:43124/mcp\"\nbearer_token_env_var = \"DAIMON_MCP_CAPABILITY\"\n\n[shell_environment_policy]\ninherit=\"all\"\nignore_default_excludes=false\nexclude=[]\ninclude_only=[]\nset={\"LD_PRELOAD\"=\"/usr/local/lib/daimon/libfaketime.so.1\",\"FAKETIME_TIMESTAMP_FILE\"=\"/run/daimon-engine-broker/task-clock/faketime\",\"FAKETIME_DONT_FAKE_MONOTONIC\"=\"1\",\"LC_ALL\"=\"C\"}\n" + } +} as const; + /** Daimon `renderGrokWorkerSandboxProfile(denyPaths)` samples; the Spawnfile mirror must reproduce them byte for byte. */ export const DAIMON_GROK_WORKER_PROFILE_SAMPLES = [ { diff --git a/src/runtime/daimon/grokWorkerContract.test.ts b/src/runtime/daimon/grokWorkerContract.test.ts index c8fe548a..52bc75e3 100644 --- a/src/runtime/daimon/grokWorkerContract.test.ts +++ b/src/runtime/daimon/grokWorkerContract.test.ts @@ -3,7 +3,7 @@ import { createHash } from "node:crypto"; import { describe, expect, it } from "vitest"; import { DAIMON_GROK_BROKER_MODELS, DAIMON_GROK_BROKER_REASONING_EFFORTS, DAIMON_GROK_ENGINE_BROKER } from "./contractManifest.js"; -import { DAIMON_GROK_WORKER_CONFIG_BYTES, DAIMON_GROK_WORKER_PROFILE_SAMPLES } from "./grokWorkerConfigBytes.js"; +import { DAIMON_GROK_CLOCKED_WORKER_CONFIG_BYTES, DAIMON_GROK_WORKER_CONFIG_BYTES, DAIMON_GROK_WORKER_PROFILE_SAMPLES } from "./grokWorkerConfigBytes.js"; import { renderDaimonGrokWorkerSandboxProfile, resolveDaimonGrokWorkerConfig } from "./grokWorkerContract.js"; const sha256 = (value: string): string => createHash("sha256").update(value).digest("hex"); @@ -17,6 +17,13 @@ describe("vendored Daimon Grok worker contract", () => { expect(sha256(resolved.bytes)).toBe(DAIMON_GROK_ENGINE_BROKER.worker.configSha256[model][effort]); expect(resolved.bytes).toContain(`model = "${model}"`); expect(resolved.bytes).toContain(`value = "${effort}"`); + expect(resolved.bytes).not.toMatch(/LD_PRELOAD|FAKETIME/u); + const clocked = resolveDaimonGrokWorkerConfig(model, effort, "clocked"); + expect(clocked.bytes).toBe(DAIMON_GROK_CLOCKED_WORKER_CONFIG_BYTES[model][effort]); + expect(clocked.sha256).toBe(DAIMON_GROK_ENGINE_BROKER.worker.clockedConfigSha256[model][effort]); + expect(sha256(clocked.bytes)).toBe(clocked.sha256); + expect(clocked.bytes).toContain(DAIMON_GROK_ENGINE_BROKER.worker.taskClock.preloadPath); + expect(clocked.bytes).toContain(DAIMON_GROK_ENGINE_BROKER.worker.taskClock.timestampFilePath); } } }); @@ -26,9 +33,11 @@ describe("vendored Daimon Grok worker contract", () => { ...DAIMON_GROK_WORKER_CONFIG_BYTES, "grok-4.6": { ...DAIMON_GROK_WORKER_CONFIG_BYTES["grok-4.6"], low: DAIMON_GROK_WORKER_CONFIG_BYTES["grok-4.6"].low.replace("grok-4.6", "grok-4.5") } }; - expect(() => resolveDaimonGrokWorkerConfig("grok-4.6", "low", tampered)).toThrow(/does not match the contract manifest pin/u); + expect(() => resolveDaimonGrokWorkerConfig("grok-4.6", "low", "unclocked", tampered)).toThrow(/does not match the contract manifest pin/u); const swapped = { ...DAIMON_GROK_WORKER_CONFIG_BYTES, "grok-4.6": { ...DAIMON_GROK_WORKER_CONFIG_BYTES["grok-4.6"], low: DAIMON_GROK_WORKER_CONFIG_BYTES["grok-4.6"].medium } }; - expect(() => resolveDaimonGrokWorkerConfig("grok-4.6", "low", swapped)).toThrow(/manifest pin/u); + expect(() => resolveDaimonGrokWorkerConfig("grok-4.6", "low", "unclocked", swapped)).toThrow(/manifest pin/u); + expect(() => resolveDaimonGrokWorkerConfig("grok-4.6", "low", "clocked", DAIMON_GROK_WORKER_CONFIG_BYTES)).toThrow(/manifest pin/u); + expect(() => resolveDaimonGrokWorkerConfig("grok-4.6", "low", "unclocked", DAIMON_GROK_CLOCKED_WORKER_CONFIG_BYTES)).toThrow(/manifest pin/u); }); it("renders sandbox profile bytes identical to Daimon's renderer", () => { diff --git a/src/runtime/daimon/grokWorkerContract.ts b/src/runtime/daimon/grokWorkerContract.ts index 5c0f4086..03a9fe4c 100644 --- a/src/runtime/daimon/grokWorkerContract.ts +++ b/src/runtime/daimon/grokWorkerContract.ts @@ -8,7 +8,9 @@ import { type DaimonGrokBrokerModel, type DaimonGrokBrokerReasoningEffort } from "./contractManifest.js"; -import { DAIMON_GROK_WORKER_CONFIG_BYTES } from "./grokWorkerConfigBytes.js"; +import { DAIMON_GROK_CLOCKED_WORKER_CONFIG_BYTES, DAIMON_GROK_WORKER_CONFIG_BYTES } from "./grokWorkerConfigBytes.js"; + +export type DaimonGrokWorkerConfigVariant = "unclocked" | "clocked"; export const DAIMON_GROK_WORKER_SANDBOX_PROFILE = "daimon-strict" as const; export const DAIMON_GROK_WORKER_HOME_DIRECTORY = ".grok" as const; @@ -26,9 +28,10 @@ const UNSAFE_DENY_PATH_CHARACTER = new RegExp("[\"\\\\\\u0000-\\u001f\\u007f*?[\ export const resolveDaimonGrokWorkerConfig = ( model: DaimonGrokBrokerModel, reasoningEffort: DaimonGrokBrokerReasoningEffort, - vendored: Readonly>>> = DAIMON_GROK_WORKER_CONFIG_BYTES + variant: DaimonGrokWorkerConfigVariant = "unclocked", + vendored: Readonly>>> = variant === "clocked" ? DAIMON_GROK_CLOCKED_WORKER_CONFIG_BYTES : DAIMON_GROK_WORKER_CONFIG_BYTES ): { bytes: string; sha256: string } => { - const pins = DAIMON_GROK_ENGINE_BROKER.worker.configSha256 as Readonly> | undefined>>; + const pins = (variant === "clocked" ? DAIMON_GROK_ENGINE_BROKER.worker.clockedConfigSha256 : DAIMON_GROK_ENGINE_BROKER.worker.configSha256) as Readonly> | undefined>>; const pin = pins[model]?.[reasoningEffort]; const bytes = vendored[model]?.[reasoningEffort]; if (pin === undefined || bytes === undefined || sha256(bytes) !== pin) { diff --git a/src/runtime/daimon/recording.test-helper.ts b/src/runtime/daimon/recording.test-helper.ts index f838cb20..28e41f69 100644 --- a/src/runtime/daimon/recording.test-helper.ts +++ b/src/runtime/daimon/recording.test-helper.ts @@ -1,5 +1,7 @@ -import { readFileSync } from "node:fs"; -import { parseDaimonContractManifest, type DaimonContractManifest } from "./contractManifest.js"; +import { readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { vi } from "vitest"; +import { DAIMON_CONTRACT_MANIFEST_SHA256, parseDaimonContractManifest, type DaimonContractManifest } from "./contractManifest.js"; import { DAIMON_WAKE_MOMENTS } from "./wakeMoments.js"; /** Capability fixture only: never writes or re-pins the packaged manifest. */ @@ -7,6 +9,20 @@ export const capableDaimonManifest = (): DaimonContractManifest => { const manifest = JSON.parse(readFileSync(new URL("./contract-manifest.json", import.meta.url), "utf8")); return parseDaimonContractManifest({ ...manifest, wakeMoments: DAIMON_WAKE_MOMENTS, - consumedConfigFields: [...manifest.consumedConfigFields, DAIMON_WAKE_MOMENTS.configField] + consumedConfigFields: [...new Set([...manifest.consumedConfigFields, DAIMON_WAKE_MOMENTS.configField])] }); }; + +/** Explicit test-only image authority; callers restore stubbed env and remove their fixture root. */ +export const useMatchingDaimonTestImage = (directory: string): void => { + const identity = path.join(directory, "daimon-test-identity.json"), digest = `sha256:${"a".repeat(64)}`; + writeFileSync(identity, JSON.stringify({ + capability_receipt_sha256: digest, + development: { mode: "local-development", non_production: true, unpublished: true, unsigned: true }, + image_architecture: "amd64", image_config_digest: digest, image_manifest_digest: digest, + image_reference: `127.0.0.1:54321/noopolis/spawnfile-runtime-daimon@${digest}`, + manifest_sha256: DAIMON_CONTRACT_MANIFEST_SHA256, registry_authority: "127.0.0.1:54321", + version: "spawnfile.local-daimon-runtime-identity.v3" + })); + vi.stubEnv("SPAWNFILE_DAIMON_LOCAL_RUNTIME_IDENTITY", identity); +}; diff --git a/src/runtime/daimon/scheduleAuthority.test.ts b/src/runtime/daimon/scheduleAuthority.test.ts index d9ca297a..505ce3b2 100644 --- a/src/runtime/daimon/scheduleAuthority.test.ts +++ b/src/runtime/daimon/scheduleAuthority.test.ts @@ -33,18 +33,13 @@ const scheduledTarget = async (kind: "every" | "disabled" = "every") => { afterEach(async () => { delete process.env.SPAWNFILE_DAIMON_LOCAL_RUNTIME_IDENTITY; await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); describe("Daimon schedule image authority", () => { - it("accepts the checked-in published pin, whose receipts attest the compiler's contract manifest", async () => { + it("requires a re-pin before the checked-in published image grants schedule authority", async () => { await expect(daimonAdapter.compileAgent(createPiTestNode({ runtime: { name: "daimon", options: {} }, schedule: { every: "1m", kind: "every" } }))).resolves.toMatchObject({ capabilities: expect.arrayContaining([ - expect.objectContaining({ key: "agent.schedule", outcome: "supported" }) + expect.objectContaining({ key: "agent.schedule", outcome: "degraded" }) ]) }); - const targets = await scheduledTarget(); - expect(JSON.parse(targets[0]!.files.find((file) => file.path === DAIMON_CONFIG_FILE)!.content).version).toBe("noopolis.daimon.organization-runtime.v2"); - const node = createPiTestNode({ runtime: { name: "daimon", options: {} } }); - const compiled = await daimonAdapter.compileAgent(node); - const unscheduled = await daimonAdapter.createContainerTargets!([{ emittedFiles: compiled.files, id: "agent:v1", kind: "agent", slug: "v1", value: node }]); - expect(JSON.parse(unscheduled[0]!.files.find((file) => file.path === DAIMON_CONFIG_FILE)!.content).version).toBe("noopolis.daimon.organization-runtime.v1"); + await expect(scheduledTarget()).rejects.toThrow(/does not attest organization runtime v2/u); }); it("supports active and disabled v2 states only with the canonical local receipt authority", async () => { diff --git a/src/runtime/install.test.ts b/src/runtime/install.test.ts index c49dfad9..bb2e1ccf 100644 --- a/src/runtime/install.test.ts +++ b/src/runtime/install.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { DAIMON_CONTRACT_MANIFEST_SHA256 } from "./daimon/contractManifest.js"; import { assertInstallSelectionsCoverCompileableRuntimes, @@ -17,7 +18,7 @@ describe("runtime install selection", () => { ]); }); - it("resolves Daimon install selection from the pinned runtime image", async () => { + it("preserves the published install pin and its old manifest; requires a re-pin", async () => { await expect(resolveRuntimeInstallSelection("daimon")).resolves.toEqual({ capabilityReceipt: undefined, capabilityReceipts: { amd64: "sha256:4714bbf377acdb098fcf90c0c91e55494f0996fca77e28cd599b6a72c7cac2dc", arm64: "sha256:f48018b729b984fde30af433607087c2f72019aad59ccf26d2641f18757c2dc6" }, @@ -32,6 +33,8 @@ describe("runtime install selection", () => { selectionSource: "runtime_registry_install", tag: "0.2.0-8349559" }); + const selection = await resolveRuntimeInstallSelection("daimon"); + expect(selection.kind === "container_image" && selection.contractManifestSha256).not.toBe(DAIMON_CONTRACT_MANIFEST_SHA256); }); it("resolves OpenClaw install selection from the pinned runtime image", async () => { From b257b588d3d891e60c264838423e613ae008b170 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 11 Oct 2026 08:23:45 +0200 Subject: [PATCH 2/8] feat: provision clocked Grok training slots with libfaketime and the broker clock file --- runtime-images/training/AGENTS.md | 11 +++- runtime-images/training/Dockerfile | 13 +++- scripts/AGENTS.md | 7 +- scripts/training-image-modes.test.ts | 30 ++++++++- scripts/training-image-modes.ts | 15 ++++- scripts/verify-training-image-modes.ts | 4 +- src/compiler/AGENTS.md | 1 + src/compiler/containerDaimonGrokTaskClock.ts | 41 ++++++++++++ ...tainerDaimonGrokWorkerProvisioning.test.ts | 37 +++++++++-- .../containerDaimonGrokWorkerProvisioning.ts | 4 +- .../containerDaimonGrokWorkerRender.ts | 5 +- src/compiler/training/broker/AGENTS.md | 24 ++++++- src/compiler/training/broker/paths.ts | 5 +- .../training/broker/processes.test.ts | 53 +++++++++++++++ src/compiler/training/broker/processes.ts | 8 ++- .../training/broker/projection.test.ts | 65 +++++++++++++++++++ src/compiler/training/broker/projection.ts | 13 +++- src/compiler/training/broker/provisioning.ts | 3 + .../training/broker/registration.test.ts | 28 +++++++- src/compiler/training/broker/registration.ts | 7 +- src/compiler/training/broker/runtime.ts | 16 +++-- .../training/broker/taskClock.test.ts | 64 ++++++++++++++++++ src/compiler/training/broker/taskClock.ts | 42 ++++++++++++ .../training/preparation/contextModes.test.ts | 1 + 24 files changed, 467 insertions(+), 30 deletions(-) create mode 100644 src/compiler/containerDaimonGrokTaskClock.ts create mode 100644 src/compiler/training/broker/processes.test.ts create mode 100644 src/compiler/training/broker/projection.test.ts create mode 100644 src/compiler/training/broker/taskClock.test.ts create mode 100644 src/compiler/training/broker/taskClock.ts diff --git a/runtime-images/training/AGENTS.md b/runtime-images/training/AGENTS.md index 0e6727a0..7baf943a 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` 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` Daimon identities, 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.v3` 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 7bed0cf6..866367a3 100644 --- a/runtime-images/training/Dockerfile +++ b/runtime-images/training/Dockerfile @@ -10,10 +10,21 @@ 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 -# build context, so it sits first: it is the least frequently changing layer. +# build context, so it precedes dependency and source layers. RUN for fixed_uid in 2000 2100 2200; do \ groupadd -K GID_MIN=1 --gid "$fixed_uid" "daimon-$fixed_uid" \ && useradd -K UID_MIN=1 --no-create-home --no-log-init --uid "$fixed_uid" --gid "$fixed_uid" \ 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/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/src/compiler/AGENTS.md b/src/compiler/AGENTS.md index 2c8ff610..95653df4 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 # Clocked config selection/count gate and shared root clock 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..29c7864c --- /dev/null +++ b/src/compiler/containerDaimonGrokTaskClock.ts @@ -0,0 +1,41 @@ +import { SpawnfileError } from "../shared/index.js"; +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"); + +/** A fixed clock file cannot serve concurrent registrations, even mixed variants. */ +export const grokProvisioningConfigPins = (registrations: readonly DaimonGrokRegistration[]) => { + if (!registrations.some((entry) => entry.workerConfigVariant === "clocked")) return broker.worker.configSha256; + if (registrations.length !== clock.maxRegistrations) { + throw new SpawnfileError("compile_error", "Clocked Grok slots require exactly one registration"); + } + return broker.worker.clockedConfigSha256; +}; + +/** Lines inside the shared root program, after its canonical-path and ownership helpers. */ +export const renderDaimonGrokTaskClockProvisioning = (registrations: readonly DaimonGrokRegistration[]): string[] => { + grokProvisioningConfigPins(registrations); + if (!registrations.some((entry) => entry.workerConfigVariant === "clocked")) return []; + return [ + `const taskClock = ${JSON.stringify(clock)};`, + // 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.directoryPath);; 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.directoryPath, 0, 0, taskClock.directoryMode); assertCanonical(taskClock.directoryPath, 'task clock');", + "ensureExactFile(taskClock.timestampFilePath, '+0\\n', taskClock.fileMode); fs.chownSync(taskClock.timestampFilePath, taskClock.ownerUid, taskClock.ownerUid);", + "withMode(taskClock.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..65996483 100644 --- a/src/compiler/containerDaimonGrokWorkerProvisioning.test.ts +++ b/src/compiler/containerDaimonGrokWorkerProvisioning.test.ts @@ -9,11 +9,14 @@ 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 { 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 +42,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 +67,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 +83,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 +108,39 @@ 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 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 4f4de43e..fee68a39 100644 --- a/src/compiler/containerDaimonGrokWorkerProvisioning.ts +++ b/src/compiler/containerDaimonGrokWorkerProvisioning.ts @@ -1,5 +1,6 @@ import { DAIMON_GROK_ENGINE_BROKER } from "../runtime/daimon/contractManifest.js"; import { DAIMON_BROKER_UID, DAIMON_ORGANIZATION_UID } from "../runtime/daimon/runtimeIdentity.js"; +import { grokProvisioningConfigPins, renderDaimonGrokTaskClockProvisioning } from "./containerDaimonGrokTaskClock.js"; import { DAIMON_GROK_DENIED_STATE_ROOTS, DAIMON_GROK_OPTIONAL_DENY_PATHS, @@ -67,7 +68,7 @@ export const renderDaimonGrokWorkerProvisioning = ( ): string[] => [ `const grokWorkers = ${JSON.stringify(registrations.map(programRegistration))};`, `const optionalDenyPaths = new Set(${JSON.stringify([...optionalDenyPaths])});`, - `const pinnedConfigSha256 = ${JSON.stringify(DAIMON_GROK_ENGINE_BROKER.worker.configSha256)};`, + `const pinnedConfigSha256 = ${JSON.stringify(grokProvisioningConfigPins(registrations))};`, "const sha256Hex = (value) => crypto.createHash('sha256').update(value).digest('hex');", "for (const entry of grokWorkers) { if (sha256Hex(entry.config) !== entry.configSha256 || !Object.hasOwn(pinnedConfigSha256, entry.model) || !Object.hasOwn(pinnedConfigSha256[entry.model], entry.reasoningEffort) || pinnedConfigSha256[entry.model][entry.reasoningEffort] !== entry.configSha256 || sha256Hex(entry.profile) !== entry.profileSha256 || entry.denyPaths.length === 0) throw new Error(`Grok worker contract bytes for ${entry.agentId} do not match their pins`); }", // Root holds no CAP_FOWNER: every mode change reclaims the inode, sets the mode, then restores or hands over ownership. @@ -81,6 +82,7 @@ export const renderDaimonGrokWorkerProvisioning = ( "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}); };`, `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');`, + ...renderDaimonGrokTaskClockProvisioning(registrations), // 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'); 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'); }`, "for (const denied of optionalDenyPaths) { try { fs.lstatSync(denied); } catch (error) { if (error.code !== 'ENOENT') throw error; fs.mkdirSync(denied, { mode: 0o700 }); fs.chownSync(denied, 0, 0); fs.chmodSync(denied, 0o700); } }", diff --git a/src/compiler/containerDaimonGrokWorkerRender.ts b/src/compiler/containerDaimonGrokWorkerRender.ts index 59952c00..45cbd92d 100644 --- a/src/compiler/containerDaimonGrokWorkerRender.ts +++ b/src/compiler/containerDaimonGrokWorkerRender.ts @@ -23,7 +23,8 @@ import { DAIMON_GROK_WORKER_HOME_DIRECTORY, daimonGrokWorkerSandboxProfileSha256, renderDaimonGrokWorkerSandboxProfile, - resolveDaimonGrokWorkerConfig + resolveDaimonGrokWorkerConfig, + type DaimonGrokWorkerConfigVariant } from "../runtime/daimon/grokWorkerContract.js"; import { DAIMON_FIRST_WORKER_UID } from "../runtime/daimon/runtimeIdentity.js"; import type { RuntimeTargetPlan } from "./containerArtifactsTypes.js"; @@ -93,6 +94,8 @@ export interface DaimonGrokRegistration { agentId: string; config: string; configSha256: string; + /** Omission preserves production's unclocked config and multi-worker layout. */ + workerConfigVariant?: DaimonGrokWorkerConfigVariant; denyPaths: string[]; /** Deny entries the main entrypoint materializes after root provisioning (workspace resource backings); absent is allowed there, a symlink never. */ deferredDenyPaths: string[]; diff --git a/src/compiler/training/broker/AGENTS.md b/src/compiler/training/broker/AGENTS.md index fc617dae..6843459c 100644 --- a/src/compiler/training/broker/AGENTS.md +++ b/src/compiler/training/broker/AGENTS.md @@ -22,6 +22,10 @@ container that runs a brokered Grok subject beside the evaluator. no caller-supplied path or command) and owns `startTrainingSlot`, the only way a slot comes up: `provision → start → canaries → generation → receipt`. - `receipt.ts` writes `noopolis.daimon.grok-slot-preflight.v2`. +- `taskClock.ts` reclaims the clock on recycle, + and checks installed config bytes, clock modes, library identity and worker + access before the receipt. `projection.ts` requires Daimon's projection v2 + 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. @@ -29,6 +33,20 @@ container that runs a brokered Grok subject beside the evaluator. Local constraints: +- Training always installs the clocked config; exactly one registration may use + the container's fixed clock directory. The shared root renderer creates + `/run/daimon-engine-broker/task-clock` as `2100:2100 0755` and `faketime` as + `2100:2100 0644`, initially `+0\n`. Reclaim that subtree before clearing the + control root on recycle; 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 @@ -77,9 +95,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.ts b/src/compiler/training/broker/paths.ts index 298a308d..a029a0b9 100644 --- a/src/compiler/training/broker/paths.ts +++ b/src/compiler/training/broker/paths.ts @@ -76,6 +76,9 @@ export const TRAINING_WORKER_ROOT = DAIMON_WORKER_ROOT; export const TRAINING_SLOT_INDEX = 0; export const TRAINING_WORKER_UID = DAIMON_FIRST_WORKER_UID + TRAINING_SLOT_INDEX; export const TRAINING_WORKER_HOME = path.posix.join(TRAINING_WORKER_ROOT, String(TRAINING_WORKER_UID)); +/** Keep /run's read-only directory mount visible for atomic clock-file replacement. */ +export const TRAINING_BROKER_SOCKET_PATHS = [DAIMON_GROK_ENGINE_BROKER.controlSocketPath, + DAIMON_GROK_ENGINE_BROKER.backendSocketPath, DAIMON_GROK_ENGINE_BROKER.launcherSocketPath] as const; /** * Paideia's own fixed container paths (`containerPaths` in its native launch @@ -121,7 +124,7 @@ export const TRAINING_EVALUATOR_PATHS: readonly { role: "run-root" | "context" | { role: "context", path: TRAINING_INFERENCE_DIRECTORY }, { role: "context", path: TRAINING_SLOT_TURN_STORE }, { role: "context", path: TRAINING_SUPERVISOR_DIRECTORY }, - { role: "context", path: path.posix.dirname(DAIMON_GROK_ENGINE_BROKER.controlSocketPath) }, + ...TRAINING_BROKER_SOCKET_PATHS.map((socket) => ({ role: "context" as const, path: socket })), { role: "context", path: path.posix.dirname(DAIMON_GROK_ENGINE_BROKER.registrationPath) }, { role: "context", path: DAIMON_GROK_TURN_USAGE_LEDGER.directoryPath }, { role: "context", path: DAIMON_WAKE_FUSE_DIRECTORY } 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..915608c1 --- /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.v1", 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 v2 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.v2", + 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, workerConfigVariant: "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 d0248e7f..bacba19c 100644 --- a/src/compiler/training/broker/projection.ts +++ b/src/compiler/training/broker/projection.ts @@ -17,7 +17,7 @@ import { TRAINING_SLOT_RUNTIME_HOME, TRAINING_SLOT_STATE_ROOT, TRAINING_SLOT_WOR 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. */ @@ -33,6 +33,7 @@ export const loadDaimonProjectionModule = async (load: () => Promise = if (typeof daimon.resolveOrganizationGrokBrokerProjection !== "function" || typeof daimon.grokBrokerProjectionSha256 !== "function" || daimon.GROK_ENGINE_BROKER?.projectionVersion !== DAIMON_GROK_ENGINE_BROKER.projectionVersion || daimon.GROK_ENGINE_BROKER.slotPreflightVersion !== DAIMON_GROK_ENGINE_BROKER.slotPreflightVersion + || daimon.GROK_ENGINE_BROKER.controlProtocolVersion !== DAIMON_GROK_ENGINE_BROKER.controlProtocolVersion || 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"); } @@ -84,6 +85,7 @@ export const resolveTrainingGrokProjection = async ( slot: registration.slot, workerUid: registration.uid, workerHomePath: TRAINING_WORKER_HOME, + workerConfigVariant: "clocked", architecture: declaration.architecture, usageLedgerPath: registration.usageLedgerPath, limits: declaration.limits, @@ -95,6 +97,15 @@ export const resolveTrainingGrokProjection = async ( seccompProfileSha256: declaration.seccompProfileSha256, profileSha256: registration.profileSha256 }) as unknown as Record; + const clock = DAIMON_GROK_ENGINE_BROKER.worker.taskClock; + const projectedClock = projection.taskClock as Record | undefined; + if (registration.workerConfigVariant !== "clocked" || projection.version !== DAIMON_GROK_ENGINE_BROKER.projectionVersion + || projection.workerConfigVariant !== "clocked" || projection.workerConfigSha256 !== registration.configSha256 + || projection.profileSha256 !== registration.profileSha256 || !projectedClock + || Object.keys(projectedClock).length !== Object.keys(clock).length + || Object.entries(clock).some(([key, value]) => projectedClock[key] !== value)) { + throw new SpawnfileError("runtime_error", "Daimon's Grok broker projection differs from the provisioned clocked worker config"); + } const denied = [...(projection.denyPaths as string[])]; if (denied.length !== registration.denyPaths.length || denied.some((entry, index) => entry !== registration.denyPaths[index])) { throw new SpawnfileError("runtime_error", "Daimon's Grok broker projection deny list differs from the provisioned sandbox profile"); diff --git a/src/compiler/training/broker/provisioning.ts b/src/compiler/training/broker/provisioning.ts index 68030085..f7a3707f 100644 --- a/src/compiler/training/broker/provisioning.ts +++ b/src/compiler/training/broker/provisioning.ts @@ -30,6 +30,7 @@ import { TRAINING_WORKER_UID } from "./paths.js"; import { trainingNamespaceDenialMechanism } from "./seccompRoutes.js"; +import { renderTrainingTaskClockAssertions, renderTrainingTaskClockReset } from "./taskClock.js"; const quote = (value: string): string => `'${value.replace(/'/g, `'"'"'`)}'`; @@ -127,10 +128,12 @@ export const renderTrainingBrokerProvisioning = (registration: DaimonGrokRegistr ...[...trainingSlotDirectories()].sort((left, right) => right.path.split("/").length - left.path.split("/").length) .map((entry) => `provision_dir ${quote(entry.path)} ${entry.mode} ${entry.uid} ${entry.gid}`), ...renderDaimonGrokHostPreflight(), + ...renderTrainingTaskClockReset(), ...renderDaimonBrokerProvisioningProgram([registration], [], { turnStore: TRAINING_SLOT_TURN_STORE, inferenceLedgerPath: TRAINING_INFERENCE_LEDGER }, "clear", TRAINING_OPTIONAL_DENY_DIRECTORIES, TRAINING_BROKER_TMPDIR), + ...renderTrainingTaskClockAssertions(registration), // The shared program leaves the broker's `/etc` root `0555 root:root`, which every uid can list. Training // denies that directory to its worker, and a canary can only observe a denial the kernel actually enforces. `chmod 0550 /etc/daimon-engine-broker; chown 0:${DAIMON_BROKER_UID} /etc/daimon-engine-broker`, diff --git a/src/compiler/training/broker/registration.test.ts b/src/compiler/training/broker/registration.test.ts index ac7009ef..0dd3eb01 100644 --- a/src/compiler/training/broker/registration.test.ts +++ b/src/compiler/training/broker/registration.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it } from "vitest"; +import { DAIMON_GROK_ENGINE_BROKER } from "../../../runtime/daimon/contractManifest.js"; +import { DAIMON_GROK_CLOCKED_WORKER_CONFIG_BYTES } from "../../../runtime/daimon/grokWorkerConfigBytes.js"; +import { renderDaimonGrokWorkerProvisioning } from "../../containerDaimonGrokWorkerProvisioning.js"; import { renderDaimonGrokServiceConfig } from "../../containerDaimonGrokWorkerRender.js"; import { trainingBrokerTmpfsTargets } from "../container/security.js"; @@ -8,6 +11,7 @@ import { resolveTrainingGrokDenyPaths, resolveTrainingGrokRegistration } from ". import { TRAINING_ADDED_DENY_PATHS, TRAINING_BROKER_TMPDIR, + TRAINING_BROKER_SOCKET_PATHS, TRAINING_SLOT_RUNTIME_HOME, TRAINING_WORKER_HOME, TRAINING_SLOT_WORKSPACE, @@ -35,7 +39,7 @@ describe("training Grok slot registration", () => { expect(denied.some((entry) => covered === entry || covered.startsWith(`${entry}/`)), covered).toBe(true); } for (const entry of [...TRAINING_EVALUATOR_ROOTS.map((role) => role.path), - "/run/daimon-engine-broker", "/etc/daimon-engine-broker", TRAINING_GRANT_HOME_ROOT, TRAINING_INFERENCE_DIRECTORY, + ...TRAINING_BROKER_SOCKET_PATHS, "/etc/daimon-engine-broker", TRAINING_GRANT_HOME_ROOT, TRAINING_INFERENCE_DIRECTORY, TRAINING_SLOT_STATE_ROOT, TRAINING_SLOT_TURN_STORE, TRAINING_REALM_MOUNT]) { expect(denied, entry).toContain(entry); } @@ -77,6 +81,28 @@ describe("training Grok slot registration", () => { expect(slot().usageLedgerPath.startsWith("/var/lib/spawnfile/daimon/usage")).toBe(false); }); + it("selects the vendored clocked bytes and keeps the directory visible through strict's read-only /run mount", () => { + const registration = slot(), clock = DAIMON_GROK_ENGINE_BROKER.worker.taskClock; + expect(registration.workerConfigVariant).toBe("clocked"); + expect(registration.config).toBe(DAIMON_GROK_CLOCKED_WORKER_CONFIG_BYTES["grok-4.6"].low); + expect(registration.configSha256).toBe(DAIMON_GROK_ENGINE_BROKER.worker.clockedConfigSha256["grok-4.6"].low); + expect(registration.profile).toContain('extends = "strict"'); + expect(registration.profile).not.toMatch(/write|read_write/u); + for (const file of [clock.timestampFilePath, clock.directoryPath, clock.preloadPath, + "/usr/lib/x86_64-linux-gnu/faketime/libfaketime.so.1", "/usr/lib/aarch64-linux-gnu/faketime/libfaketime.so.1"]) { + expect(registration.denyPaths.some((denied) => file === denied || file.startsWith(`${denied}/`))).toBe(false); + expect(() => resolveTrainingGrokDenyPaths([...TRAINING_ADDED_DENY_PATHS, file])).toThrow(/task clock|base profile grant/u); + } + expect(() => resolveTrainingGrokDenyPaths([...TRAINING_ADDED_DENY_PATHS, "/run/daimon-engine-broker"])).toThrow(/task clock/u); + }); + + it("refuses multiple registrations when any slot is clocked", () => { + for (const variant of ["clocked", "unclocked"] as const) { + expect(() => renderDaimonGrokWorkerProvisioning([slot(), { ...slot(), agentId: "agent:peer", workerConfigVariant: variant }])) + .toThrow(/exactly one registration/u); + } + }); + it("refuses an undeclared model or reasoning effort", () => { expect(() => resolveTrainingGrokRegistration({ agentId: "agent:author", model: "grok-9" as never, reasoningEffort: "low" })).toThrow(/declared broker model/u); }); diff --git a/src/compiler/training/broker/registration.ts b/src/compiler/training/broker/registration.ts index ffd9981c..09128ee9 100644 --- a/src/compiler/training/broker/registration.ts +++ b/src/compiler/training/broker/registration.ts @@ -63,11 +63,15 @@ export const resolveTrainingGrokDenyPaths = (added: readonly string[] = TRAINING ...DAIMON_GROK_BASE_PROFILE_GRANTS, TRAINING_SLOT_WORKSPACE, grokHome, path.posix.join(grokHome, "sessions"), path.posix.join(TRAINING_WORKER_HOME, "tmp") ]; + const clock = DAIMON_GROK_ENGINE_BROKER.worker.taskClock; + const clockPaths = [clock.directoryPath, clock.preloadPath, + "/usr/lib/x86_64-linux-gnu/faketime/libfaketime.so.1", "/usr/lib/aarch64-linux-gnu/faketime/libfaketime.so.1"]; const candidates = [...new Set([...daimonOwn, ...added])].sort(); for (const entry of candidates) { assertCanonicalRegisteredPath("deny path", entry); const grant = grants.find((candidate) => within(candidate, entry)); if (grant) fail(`Training Grok deny path ${entry} equals or contains the base profile grant ${grant}; Grok refuses such a profile`); + if (within(entry, clock.directoryPath) || clockPaths.some((required) => within(required, entry))) fail(`Training Grok deny path ${entry} hides the task clock`); if (within(TRAINING_SLOT_WORKSPACE, entry) || within(TRAINING_WORKER_HOME, entry) || within(TRAINING_SLOT_RUNTIME_HOME, entry)) { fail(`Training Grok deny path ${entry} would hide the subject's own workspace, home, or runtime home`); } @@ -106,7 +110,7 @@ export const resolveTrainingGrokRegistration = (input: TrainingGrokSlotInput): D } const home = assertCanonicalRegisteredPath("home", TRAINING_WORKER_HOME); const grokHome = path.posix.join(home, DAIMON_GROK_WORKER_HOME_DIRECTORY); - const config = resolveDaimonGrokWorkerConfig(input.model, input.reasoningEffort); + const config = resolveDaimonGrokWorkerConfig(input.model, input.reasoningEffort, "clocked"); const denyPaths = resolveTrainingGrokDenyPaths(input.denyPaths); const profile = renderDaimonGrokWorkerSandboxProfile(denyPaths); const eventsPath = path.posix.join(grokHome, DAIMON_GROK_ENGINE_BROKER.worker.home.sandboxEvents.relativePath); @@ -119,6 +123,7 @@ export const resolveTrainingGrokRegistration = (input: TrainingGrokSlotInput): D config: config.bytes, ...(input.limits === undefined ? {} : { limits: { ...input.limits } }), configSha256: config.sha256, + workerConfigVariant: "clocked", deferredDenyPaths: denyPaths.filter((entry) => TRAINING_DEFERRED_DENY_PATHS.includes(entry)), denyPaths, eventsPath, diff --git a/src/compiler/training/broker/runtime.ts b/src/compiler/training/broker/runtime.ts index 685b1fb0..1e81dff5 100644 --- a/src/compiler/training/broker/runtime.ts +++ b/src/compiler/training/broker/runtime.ts @@ -14,6 +14,7 @@ import { MOUNT_AWARE_CLEAR_HELPER } from "../../containerDaimonBrokerRender.js"; import { renderTrainingBrokerProvisioning, renderTrainingIdentities } from "./provisioning.js"; import { startBrokerProcesses, stopBrokerProcesses, type BrokerChild } from "./processes.js"; import { buildTrainingSlotReceipt, readGrokExecutableSha256, resolveTrainingCanaries } from "./receipt.js"; +import { renderTrainingTaskClockAssertions } from "./taskClock.js"; import type { TrainingSlotRuntime } from "./supervisor.js"; import { DAIMON_ORGANIZATION_UID, @@ -155,12 +156,15 @@ export const createTrainingSlotRuntime = (options: TrainingSlotRuntimeOptions): if (result.stdout.trim()) log(`provisioning: ${result.stdout.trim().slice(-2048)}`); }, start: async () => { children = await startBrokerProcesses({ log, procRoot: options.procRoot }); }, - canaries: async () => 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 - }), + 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(TRAINING_SLOT_GENERATION_FILE, "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..84d1c837 --- /dev/null +++ b/src/compiler/training/broker/taskClock.test.ts @@ -0,0 +1,64 @@ +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); + expect(probe).toContain(target); + } + expect(() => renderTrainingTaskClockAssertions({ ...slot, workerConfigVariant: "unclocked" })).toThrow(/pinned clocked/u); + expect(() => renderTrainingTaskClockAssertions({ ...slot, configSha256: "a".repeat(64) })).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..bedcd5c6 --- /dev/null +++ b/src/compiler/training/broker/taskClock.ts @@ -0,0 +1,42 @@ +import { DAIMON_GROK_ENGINE_BROKER } from "../../../runtime/daimon/contractManifest.js"; +import { SpawnfileError } from "../../../shared/index.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 on recycle. */ +export const renderTrainingTaskClockReset = (): string[] => [ + `test ! -L ${quote(clock.directoryPath)}`, + `if [ -d ${quote(clock.directoryPath)} ]; then chown -R 0:0 ${quote(clock.directoryPath)}; chmod -R u+rwX ${quote(clock.directoryPath)}; fi` +]; + +/** Before a receipt, bind the actual installed bytes and worker access to the clocked projection. */ +export const renderTrainingTaskClockAssertions = (registration: DaimonGrokRegistration): string[] => { + const pin = DAIMON_GROK_ENGINE_BROKER.worker.clockedConfigSha256[registration.model][registration.reasoningEffort]; + if (registration.workerConfigVariant !== "clocked" || registration.configSha256 !== pin) { + throw new SpawnfileError("runtime_error", "Training slot requires the pinned clocked worker config"); + } + const worker = [ + `test -r ${quote(clock.timestampFilePath)}`, `test ! -w ${quote(clock.timestampFilePath)}`, + `test -x ${quote(clock.directoryPath)}`, `test ! -w ${quote(clock.directoryPath)}`, + `test -r ${quote(clock.preloadPath)}` + ].join(" && "); + return [ + `test ! -L ${quote(clock.directoryPath)} && test -d ${quote(clock.directoryPath)}`, + `test ! -L ${quote(clock.timestampFilePath)} && test -f ${quote(clock.timestampFilePath)}`, + `test "$(stat -c '%u:%g %a' ${quote(clock.directoryPath)})" = "${clock.ownerUid}:${clock.ownerUid} ${clock.directoryMode.toString(8)}"`, + `test "$(stat -c '%u:%g %a %h' ${quote(clock.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..a18a8049 100644 --- a/src/compiler/training/preparation/contextModes.test.ts +++ b/src/compiler/training/preparation/contextModes.test.ts @@ -99,6 +99,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", From 1be8b2386af71b8f118dafb5c7daf7ca86cc8022 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 11 Oct 2026 08:23:45 +0200 Subject: [PATCH 3/8] docs: document clocked training slots and the Daimon release order --- specs/CONTAINERS.md | 25 ++++++++++++++++++++++++ specs/TRAINING.md | 12 ++++++++++++ specs/TRAINING_CONTAINERS.md | 37 ++++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+) diff --git a/specs/CONTAINERS.md b/specs/CONTAINERS.md index 34951f5b..5c5d55ce 100644 --- a/specs/CONTAINERS.md +++ b/specs/CONTAINERS.md @@ -187,6 +187,31 @@ 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 v2. 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..1280de5f 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 v3; the broker atomically publishes the offset to `/run/daimon-engine-broker/task-clock/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,17 @@ 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 have exactly one registration; turns cannot 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 ce2c81a5..38306fea 100644 --- a/specs/TRAINING_CONTAINERS.md +++ b/specs/TRAINING_CONTAINERS.md @@ -280,6 +280,43 @@ one Grok credential in the container. ### Privilege table +The v3 slot always selects Daimon's manifest-v4 clocked worker config. 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 `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. Exactly one registration may use a clock-enabled container. +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 v2, including +`workerConfigVariant: "clocked"`, `workerConfigSha256` and `taskClock`. +Paideia must request that same variant when recomputing the projection and use +control protocol v3; an old projection or protocol is refused. Each recycle +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) | From 17cd801831f57d6d27a80082e4d66f2ce001d7c3 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 11 Oct 2026 08:37:21 +0200 Subject: [PATCH 4/8] fix: accept the vendored v4 contract manifest when building the Daimon runtime image --- runtime-images/daimon/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runtime-images/daimon/Dockerfile b/runtime-images/daimon/Dockerfile index 834a264e..8345cd59 100644 --- a/runtime-images/daimon/Dockerfile +++ b/runtime-images/daimon/Dockerfile @@ -161,7 +161,7 @@ RUN test -n "${GROK_CLI_VERSION}" \ && expected_manifest="$(cat ${RUNTIME_ROOT}/contract-manifest.sha256)" \ && test "${expected_manifest}" = "${DAIMON_MANIFEST_SHA256}" \ && test "$(sha256sum ${RUNTIME_ROOT}/contract-manifest.json | awk '{print "sha256:" $1}')" = "${expected_manifest}" \ - && node -e 'const fs=require("fs"); const raw=fs.readFileSync(process.argv[1],"utf8"); const c=v=>Array.isArray(v)?"["+v.map(c).join(",")+"]":v&&typeof v==="object"?"{"+Object.keys(v).sort().map(k=>JSON.stringify(k)+":"+c(v[k])).join(",")+"}":JSON.stringify(v); const m=JSON.parse(raw),s=["agy","codex","grok"],w=["manual","message","schedule","external"],e={codex:["codex-auth",".daimon-inbound/codex-auth",".codex/auth.json"]},a=m.agySubscriptionRealm,g=m.grokSubscriptionRealm,d=m.deliverySemantics; if(raw!==c(m)+"\n"||m.version!=="noopolis.daimon.runtime-contract-manifest.v3"||m.organizationRuntimeConfigV2Schema?.$id!=="noopolis.daimon.organization-runtime.v2"||JSON.stringify(m.supportedEngineKinds)!==JSON.stringify(s)||JSON.stringify(m.wakeAcceptanceTypes)!==JSON.stringify(w)||d?.terminalReceiptHorizon!==2048||d?.recovery!=="at-least-once-with-stable-wake-id"||d?.externalEffectsExactlyOnce!==false||!m.engineCredentialMaterial||Object.keys(m.engineCredentialMaterial).length!==1||Object.entries(e).some(([k,[s,i,d]])=>{const x=m.engineCredentialMaterial[k];return !x||x.sourceSlot!==s||x.sourceRelativePath!==i||x.destinationRelativePath!==d||x.directoryMode!==448||x.fileMode!==384;})||!a||a.durableMountPath!=="/var/lib/spawnfile/daimon/agy-subscription-realm"||a.unlockMountPath!=="/var/lib/spawnfile/daimon/agy-unlock-secret"||a.unlockSourceSlot!=="agy-unlock-secret"||a.directoryMode!==448||a.fileMode!==384||a.maxUnlockBytes!==4096||!g||g.durableMountPath!=="/var/lib/spawnfile/daimon/grok-subscription-realm"||g.bootstrapMountPath!=="/var/lib/spawnfile/daimon/grok-bootstrap-auth"||g.bootstrapSourceSlot!=="grok-auth"||g.agentCredentialRelativePath!==".grok/auth.json"||g.directoryMode!==448||g.fileMode!==384||g.maxCredentialBytes!==65536)process.exit(1)' ${RUNTIME_ROOT}/contract-manifest.json \ + && node -e 'const fs=require("fs"); const raw=fs.readFileSync(process.argv[1],"utf8"); const c=v=>Array.isArray(v)?"["+v.map(c).join(",")+"]":v&&typeof v==="object"?"{"+Object.keys(v).sort().map(k=>JSON.stringify(k)+":"+c(v[k])).join(",")+"}":JSON.stringify(v); const m=JSON.parse(raw),s=["agy","codex","grok"],w=["manual","message","schedule","external"],e={codex:["codex-auth",".daimon-inbound/codex-auth",".codex/auth.json"]},a=m.agySubscriptionRealm,g=m.grokSubscriptionRealm,d=m.deliverySemantics; if(raw!==c(m)+"\n"||m.version!=="noopolis.daimon.runtime-contract-manifest.v4"||m.organizationRuntimeConfigV2Schema?.$id!=="noopolis.daimon.organization-runtime.v2"||JSON.stringify(m.supportedEngineKinds)!==JSON.stringify(s)||JSON.stringify(m.wakeAcceptanceTypes)!==JSON.stringify(w)||d?.terminalReceiptHorizon!==2048||d?.recovery!=="at-least-once-with-stable-wake-id"||d?.externalEffectsExactlyOnce!==false||!m.engineCredentialMaterial||Object.keys(m.engineCredentialMaterial).length!==1||Object.entries(e).some(([k,[s,i,d]])=>{const x=m.engineCredentialMaterial[k];return !x||x.sourceSlot!==s||x.sourceRelativePath!==i||x.destinationRelativePath!==d||x.directoryMode!==448||x.fileMode!==384;})||!a||a.durableMountPath!=="/var/lib/spawnfile/daimon/agy-subscription-realm"||a.unlockMountPath!=="/var/lib/spawnfile/daimon/agy-unlock-secret"||a.unlockSourceSlot!=="agy-unlock-secret"||a.directoryMode!==448||a.fileMode!==384||a.maxUnlockBytes!==4096||!g||g.durableMountPath!=="/var/lib/spawnfile/daimon/grok-subscription-realm"||g.bootstrapMountPath!=="/var/lib/spawnfile/daimon/grok-bootstrap-auth"||g.bootstrapSourceSlot!=="grok-auth"||g.agentCredentialRelativePath!==".grok/auth.json"||g.directoryMode!==448||g.fileMode!==384||g.maxCredentialBytes!==65536)process.exit(1)' ${RUNTIME_ROOT}/contract-manifest.json \ && node -e 'const fs=require("fs"); const r=JSON.parse(fs.readFileSync(process.argv[1])); const x={codex:process.argv[5],grok:process.argv[6],agy:process.argv[7]},a=r.provenance?.agy?.archive,g=r.provenance?.grok?.executable,z=process.argv[14],i=JSON.parse(fs.readFileSync(process.argv[15],"utf8")); if(r.version!=="spawnfile.daimon-runtime-capability-receipt.v1"||r.architecture!==process.argv[2]||r.manifest_sha256!==process.argv[3]||r.daimon?.package_sha256!==process.argv[4]||r.daimon?.source_sha256!==process.argv[8]||Object.entries(x).some(([k,v])=>r.engines?.[k]?.executable_sha256!==v)||a?.format!=="tar.gz"||a?.version!==process.argv[9]||a?.url!==process.argv[10]||a?.sha512!==process.argv[11]||g?.version!==process.argv[12]||g?.url!==process.argv[13]||g?.sha256!==x.grok||(z!=="none"&&(r.daimon?.source_inputs?.dependencies?.runtime_archive_sha256!==z||JSON.stringify(i)!==JSON.stringify(r.daimon.source_inputs))))process.exit(1)' ${RUNTIME_ROOT}/capability-receipt.json "${TARGETARCH}" "${expected_manifest}" "${DAIMON_PACKAGE_SHA256}" "${CODEX_CLI_SHA256}" "sha256:${GROK_CLI_SHA256#sha256:}" "sha256:${AGY_CLI_SHA256#sha256:}" "${DAIMON_SOURCE_SHA256}" "${AGY_CLI_VERSION}" "${AGY_CLI_URL}" "sha512:${AGY_CLI_SHA512#sha512:}" "${GROK_CLI_VERSION}" "${GROK_CLI_URL}" "${DAIMON_DEPENDENCY_ARCHIVE_SHA256}" /tmp/source-inputs.json \ && test "$(sha256sum ${RUNTIME_ROOT}/node_modules/@openai/codex/bin/codex.js | awk '{print "sha256:" $1}')" = "${CODEX_CLI_SHA256}" \ && test "$(sha256sum ${RUNTIME_ROOT}/bin/grok | awk '{print "sha256:" $1}')" = "sha256:${GROK_CLI_SHA256#sha256:}" \ From 520e1c22c23332d966c9eda4e8190c3a6f539c9b Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 11 Oct 2026 09:02:12 +0200 Subject: [PATCH 5/8] chore: pin the Daimon publish input to the clocked broker commit --- runtime-images/daimon/publish-inputs.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runtime-images/daimon/publish-inputs.json b/runtime-images/daimon/publish-inputs.json index 458dda54..3aef666d 100644 --- a/runtime-images/daimon/publish-inputs.json +++ b/runtime-images/daimon/publish-inputs.json @@ -19,7 +19,7 @@ "version": "0.142.3" }, "daimon": { - "commit": "7758bd70b8ba33a63bb772195574be33a7950029", + "commit": "fee115e24b8cdfa635f22235950c20a4528fe1f9", "package_version": "0.2.0", "source": "https://github.com/noopolis/daimon-harness.git" }, From 848a0da105628848bdfe40dc7c77e8ba2dc9205a Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 11 Oct 2026 09:02:12 +0200 Subject: [PATCH 6/8] test: pin the runtime image manifest validator to the vendored contract and assert clock access pairs --- scripts/daimon-publish-inputs.test.ts | 11 +++++++++++ src/compiler/training/broker/taskClock.test.ts | 3 +-- 2 files changed, 12 insertions(+), 2 deletions(-) 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/src/compiler/training/broker/taskClock.test.ts b/src/compiler/training/broker/taskClock.test.ts index 84d1c837..64f02b8f 100644 --- a/src/compiler/training/broker/taskClock.test.ts +++ b/src/compiler/training/broker/taskClock.test.ts @@ -23,8 +23,7 @@ describe("training task clock preflight", () => { // 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); - expect(probe).toContain(target); + expect(probe).toContain(`${verb} '"'"'${target}'"'"'`); } expect(() => renderTrainingTaskClockAssertions({ ...slot, workerConfigVariant: "unclocked" })).toThrow(/pinned clocked/u); expect(() => renderTrainingTaskClockAssertions({ ...slot, configSha256: "a".repeat(64) })).toThrow(/pinned clocked/u); From 682fd00a91b8547e1566db2bac373dc80a696c70 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 11 Oct 2026 09:02:12 +0200 Subject: [PATCH 7/8] docs: note the recording capability the vendored v4 manifest adds --- specs/CONTAINERS.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/specs/CONTAINERS.md b/specs/CONTAINERS.md index 5c5d55ce..c4302f1a 100644 --- a/specs/CONTAINERS.md +++ b/specs/CONTAINERS.md @@ -188,7 +188,11 @@ Daimon contract enters Spawnfile: it reads a Daimon checkout digest plus Daimon's worker `config.toml` renderer bytes. Manifest v4 adds the separate clocked worker variant, engine-broker protocol v3 -and projection v2. The published `0.2.0-8349559` pin still attests manifest +and projection v2. 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 From f207c734c18cf15b63da4a5e27740e66417a29e5 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Tue, 13 Oct 2026 15:30:48 +0200 Subject: [PATCH 8/8] chore: land clocked slots and parallel trials on current main --- .github/AGENTS.md | 3 + .github/CLAUDE.md | 1 + .github/hooks/AGENTS.md | 5 + .github/hooks/CLAUDE.md | 1 + .github/hooks/commit-msg | 12 + .github/scripts/AGENTS.md | 5 + .github/scripts/CLAUDE.md | 1 + .github/scripts/ci/AGENTS.md | 9 + .github/scripts/ci/CLAUDE.md | 1 + .github/scripts/ci/commit-message.mts | 197 ++++++++++++++++ .github/scripts/ci/commit-message.test.mts | 204 +++++++++++++++++ .github/scripts/ci/tsconfig.json | 17 ++ .github/workflows/deploy-website.yml | 55 +++-- .github/workflows/test.yml | 47 +++- .gitignore | 1 + CONTRIBUTING.md | 27 +++ fixtures/support/fakeMoltnetCli.ts | 2 +- package.json | 2 +- runtime-images/daimon/Dockerfile | 36 ++- runtime-images/daimon/publish-inputs.json | 7 +- runtime-images/training/AGENTS.md | 4 +- runtime-images/training/Dockerfile | 6 +- runtimes.yaml | 12 +- scripts/build-local-daimon-runtime.test.ts | 20 ++ scripts/vendor-daimon-grok-contract.ts | 9 +- specs/CONTAINERS.md | 2 +- specs/TRAINING.md | 5 +- specs/TRAINING_CONTAINERS.md | 31 ++- src/auth/AGENTS.md | 2 + src/auth/importers.grokConcurrency.test.ts | 95 ++++++++ src/auth/importers.ts | 23 +- src/auth/index.ts | 1 + src/auth/profileStore.ts | 4 +- src/auth/trainingGrokWriteback.test.ts | 117 ++++++++++ src/auth/trainingGrokWriteback.ts | 82 +++++++ src/auth/trainingGrokWritebackLock.test.ts | 77 +++++++ src/auth/trainingGrokWritebackLock.ts | 44 ++++ src/cli/paideiaDelegation.test.ts | 11 +- src/cli/paideiaDelegation.ts | 4 +- src/cli/targetCommands.crossProcess.test.ts | 2 +- ...argetWorldClockCrossProcess.test-helper.ts | 2 +- src/cli/trainCommand.test.ts | 14 ++ src/cli/trainCommand.ts | 2 +- src/compiler/AGENTS.md | 2 +- src/compiler/buildProject.test.ts | 2 +- src/compiler/compileProject.test.ts | 2 +- ...leProjectOrganizationReadyEvidence.test.ts | 2 +- .../containerDaimonBrokerCredential.test.ts | 72 ++++++ src/compiler/containerDaimonBrokerRender.ts | 9 +- src/compiler/containerDaimonGrokTaskClock.ts | 23 +- ...rDaimonGrokWorkerProvisioning.pins.test.ts | 70 ++++++ ...tainerDaimonGrokWorkerProvisioning.test.ts | 23 ++ .../containerDaimonGrokWorkerProvisioning.ts | 32 +-- .../containerDaimonGrokWorkerRender.ts | 24 +- src/compiler/mixedRuntimeOrg.test.ts | 2 +- src/compiler/publicDaimonHost.test.ts | 11 +- src/compiler/recordingCapability.test.ts | 24 +- src/compiler/recordingReport.test.ts | 7 +- src/compiler/training/broker/AGENTS.md | 47 ++-- .../training/broker/clockProbe.test.ts | 22 ++ src/compiler/training/broker/clockProbe.ts | 32 +++ src/compiler/training/broker/declaration.ts | 6 +- .../training/broker/entrypoint.test.ts | 7 +- src/compiler/training/broker/entrypoint.ts | 46 ++-- src/compiler/training/broker/index.ts | 5 +- src/compiler/training/broker/main.ts | 2 +- .../training/broker/paths.parallel.test.ts | 154 +++++++++++++ src/compiler/training/broker/paths.test.ts | 11 +- src/compiler/training/broker/paths.ts | 200 +++++++++-------- .../broker/projection.integration.test.ts | 46 ++++ .../training/broker/projection.test.ts | 8 +- src/compiler/training/broker/projection.ts | 37 +-- .../training/broker/provisioning.test.ts | 5 +- src/compiler/training/broker/provisioning.ts | 142 ++++++------ src/compiler/training/broker/receipt.test.ts | 2 +- .../training/broker/registration.test.ts | 62 ++--- src/compiler/training/broker/registration.ts | 69 +++--- src/compiler/training/broker/reset.test.ts | 67 ++++++ src/compiler/training/broker/reset.ts | 59 +++++ .../training/broker/runtime.canaries.test.ts | 35 +++ .../training/broker/runtime.recovery.test.ts | 211 ++++++++++++++++++ src/compiler/training/broker/runtime.test.ts | 74 ++++++ src/compiler/training/broker/runtime.ts | 181 ++++++--------- .../training/broker/supervisor.test.ts | 78 +++++-- src/compiler/training/broker/supervisor.ts | 60 +++-- .../training/broker/taskClock.test.ts | 13 +- src/compiler/training/broker/taskClock.ts | 25 ++- src/compiler/training/container/AGENTS.md | 2 + src/compiler/training/container/contract.ts | 12 +- .../training/container/grokWriteback.test.ts | 129 +++++++++++ .../training/container/grokWriteback.ts | 25 +++ src/compiler/training/container/launch.ts | 37 ++- src/compiler/training/container/prepare.ts | 12 + src/compiler/training/container/security.ts | 22 +- src/compiler/training/container/slots.test.ts | 107 +++++++++ src/compiler/training/index.ts | 2 + .../training/preparation/broker.test.ts | 18 ++ src/compiler/training/preparation/broker.ts | 11 +- .../training/preparation/contextModes.test.ts | 5 +- .../training/preparation/contract.test.ts | 2 +- src/compiler/training/preparation/contract.ts | 9 +- .../preparation/fixtures.test-helper.ts | 2 +- src/compiler/training/preparation/prepare.ts | 14 +- .../training/rewind/memoryPackage.test.ts | 14 +- .../training/rewind/moltnetJson.test.ts | 27 +++ .../training/rewind/moltnetSchemas.test.ts | 56 +++++ .../training/rewind/moltnetSchemas.ts | 7 +- .../training/rewind/moltnetSqlite.test.ts | 32 +++ src/compiler/upProject.recording.test.ts | 3 +- .../workspaceBundleFakeDocker.test-helper.ts | 2 +- src/compiler/workspaceBundleGenerated.test.ts | 12 +- src/compiler/workspaceBundleGit.test.ts | 12 +- src/compiler/workspaceGitPins.test.ts | 7 +- src/compiler/workspaceRecordingOff.test.ts | 7 +- src/dev/project.test.ts | 2 +- src/runtime/container.test.ts | 12 +- src/runtime/daimon/AGENTS.md | 4 +- src/runtime/daimon/adapter.test.ts | 11 +- src/runtime/daimon/attention.test.ts | 4 +- src/runtime/daimon/contract-manifest.json | 2 +- src/runtime/daimon/contract-manifest.sha256 | 2 +- src/runtime/daimon/contractManifest.test.ts | 11 + src/runtime/daimon/contractManifest.ts | 28 +-- src/runtime/daimon/grokWorkerConfigBytes.ts | 19 +- src/runtime/daimon/grokWorkerContract.test.ts | 23 +- src/runtime/daimon/grokWorkerContract.ts | 23 +- .../daimon/recording.test-helper.test.ts | 17 ++ src/runtime/daimon/recording.test-helper.ts | 26 +-- src/runtime/daimon/scheduleAuthority.test.ts | 11 +- src/runtime/install.test.ts | 15 +- src/target/AGENTS.md | 2 + src/target/dockerCommandExecutorCore.test.ts | 37 +++ src/target/dockerCommandExecutorCore.ts | 2 +- src/target/dockerTarget.test.ts | 19 ++ src/target/dockerTargetBinding.ts | 3 +- 135 files changed, 3292 insertions(+), 782 deletions(-) create mode 100644 .github/AGENTS.md create mode 120000 .github/CLAUDE.md create mode 100644 .github/hooks/AGENTS.md create mode 120000 .github/hooks/CLAUDE.md create mode 100755 .github/hooks/commit-msg create mode 100644 .github/scripts/AGENTS.md create mode 120000 .github/scripts/CLAUDE.md create mode 100644 .github/scripts/ci/AGENTS.md create mode 120000 .github/scripts/ci/CLAUDE.md create mode 100644 .github/scripts/ci/commit-message.mts create mode 100644 .github/scripts/ci/commit-message.test.mts create mode 100644 .github/scripts/ci/tsconfig.json create mode 100644 src/auth/importers.grokConcurrency.test.ts create mode 100644 src/auth/trainingGrokWriteback.test.ts create mode 100644 src/auth/trainingGrokWriteback.ts create mode 100644 src/auth/trainingGrokWritebackLock.test.ts create mode 100644 src/auth/trainingGrokWritebackLock.ts create mode 100644 src/compiler/containerDaimonBrokerCredential.test.ts create mode 100644 src/compiler/containerDaimonGrokWorkerProvisioning.pins.test.ts create mode 100644 src/compiler/training/broker/clockProbe.test.ts create mode 100644 src/compiler/training/broker/clockProbe.ts create mode 100644 src/compiler/training/broker/paths.parallel.test.ts create mode 100644 src/compiler/training/broker/projection.integration.test.ts create mode 100644 src/compiler/training/broker/reset.test.ts create mode 100644 src/compiler/training/broker/reset.ts create mode 100644 src/compiler/training/broker/runtime.canaries.test.ts create mode 100644 src/compiler/training/broker/runtime.recovery.test.ts create mode 100644 src/compiler/training/broker/runtime.test.ts create mode 100644 src/compiler/training/container/grokWriteback.test.ts create mode 100644 src/compiler/training/container/grokWriteback.ts create mode 100644 src/compiler/training/container/slots.test.ts create mode 100644 src/compiler/training/preparation/broker.test.ts create mode 100644 src/compiler/training/rewind/moltnetSchemas.test.ts create mode 100644 src/runtime/daimon/recording.test-helper.test.ts create mode 100644 src/target/dockerCommandExecutorCore.test.ts diff --git a/.github/AGENTS.md b/.github/AGENTS.md new file mode 100644 index 00000000..3f96e9fa --- /dev/null +++ b/.github/AGENTS.md @@ -0,0 +1,3 @@ +# Repository automation + +Owns CI workflows and opt-in commit policy tooling. No application code. diff --git a/.github/CLAUDE.md b/.github/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/.github/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/.github/hooks/AGENTS.md b/.github/hooks/AGENTS.md new file mode 100644 index 00000000..1ad53c20 --- /dev/null +++ b/.github/hooks/AGENTS.md @@ -0,0 +1,5 @@ +# Commit policy automation + +Owns the dependency-free commit checker, adjacent tests and local hook. +Keep message rules aligned with the reference and the author allowlist explicit. +No application imports, credentials, publishing or automatic hook installation. diff --git a/.github/hooks/CLAUDE.md b/.github/hooks/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/.github/hooks/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/.github/hooks/commit-msg b/.github/hooks/commit-msg new file mode 100755 index 00000000..00f005e9 --- /dev/null +++ b/.github/hooks/commit-msg @@ -0,0 +1,12 @@ +#!/bin/sh +# commit-policy-commit-message-hook: one Conventional Commits line per authored commit, no attribution. +# Run by the dispatcher the opt-in installer sets as core.hooksPath (.github/scripts/ci/commit-message.mts --install), or +# directly when core.hooksPath is .github/hooks; CI checks the same rules on every PR. Skip once with git commit --no-verify. +command -v node >/dev/null 2>&1 || { echo "commit-msg: node is missing" >&2; exit 1; } +root=$(git rev-parse --show-toplevel) || exit 1 +# Missing inputs must not silently disable validation; older branches can explicitly use --no-verify. +[ -f "$root/.github/scripts/ci/commit-message.mts" ] || { + echo "commit-msg: .github/scripts/ci/commit-message.mts is missing; restore it or explicitly use --no-verify" >&2 + exit 1 +} +exec node "$root/.github/scripts/ci/commit-message.mts" "$1" diff --git a/.github/scripts/AGENTS.md b/.github/scripts/AGENTS.md new file mode 100644 index 00000000..1ad53c20 --- /dev/null +++ b/.github/scripts/AGENTS.md @@ -0,0 +1,5 @@ +# Commit policy automation + +Owns the dependency-free commit checker, adjacent tests and local hook. +Keep message rules aligned with the reference and the author allowlist explicit. +No application imports, credentials, publishing or automatic hook installation. diff --git a/.github/scripts/CLAUDE.md b/.github/scripts/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/.github/scripts/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/.github/scripts/ci/AGENTS.md b/.github/scripts/ci/AGENTS.md new file mode 100644 index 00000000..9e7351f4 --- /dev/null +++ b/.github/scripts/ci/AGENTS.md @@ -0,0 +1,9 @@ +# Commit policy checker + +Owns the dependency-free commit-message.mts checker and adjacent tests. +Reuse the reference message rules; AUTHOR_EMAILS is the explicit author policy. +Node built-ins only in the checker. Tests use disposable Git objects and hooks; +never change the caller's Git identity, history or hooks. No application imports. + +Verify with node --test against commit-message.test.mts. +Set TMPDIR inside this worktree. Strict typing is included in npm run typecheck. diff --git a/.github/scripts/ci/CLAUDE.md b/.github/scripts/ci/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/.github/scripts/ci/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/.github/scripts/ci/commit-message.mts b/.github/scripts/ci/commit-message.mts new file mode 100644 index 00000000..c8dd1a2f --- /dev/null +++ b/.github/scripts/ci/commit-message.mts @@ -0,0 +1,197 @@ +// Commit messages: every authored commit is one Conventional Commits line with no body, trailers or attribution. +// Messages Git itself writes are accepted as Git wrote them (merges, `git revert`, `--fixup`/`--squash`). The same +// rules run in the local commit-msg hook (fast feedback) and in CI over a pull request's commits +// (the enforcement, since a hook can be skipped). Depends only on Node built-ins: CI runs it right after +// checkout, before any install. +// +// node .github/scripts/ci/commit-message.mts check one message (the commit-msg hook) +// node .github/scripts/ci/commit-message.mts --range .. check every commit in a range (CI) +// node .github/scripts/ci/commit-message.mts --install install the commit-msg hook into this clone (opt-in) +import { execFile } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { chmod, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +export const AUTHOR_EMAILS: readonly string[] = ['juancfortunatti@gmail.com']; + +export const TYPES = ['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'build', 'ci', 'chore', 'revert'] as const; +export const MAX_LENGTH = 150; +const CONVENTIONAL = new RegExp(`^(${TYPES.join('|')})(\\([a-z0-9._/-]+\\))?!?: \\S(.*\\S)?$`, 'u'); +const ATTRIBUTION = /co-authored-by|generated (with|by)|🤖/iu; +const HOOK_SOURCE = '.github/hooks/commit-msg'; +/** Where branches from before the hooks moved under `.github/` keep it; the shared dispatcher still runs theirs. */ +const EARLIER_HOOK_SOURCE = '.githooks/commit-msg'; +/** The opt-in hooks directory (`git config core.hooksPath .github/hooks`) and the one it replaced. */ +const OPT_IN_HOOKS_PATH = '.github/hooks', EARLIER_HOOKS_PATH = '.githooks'; +const HOOK_MARKER = 'commit-policy-commit-message-hook'; + +/** The message as Git stores it: comment lines and trailing blank lines removed (the default `strip` cleanup). */ +export function cleanMessage(raw: string): string[] { + const lines = raw.replace(/\r\n?/gu, '\n').split('\n').filter(line => !line.startsWith('#')); + while (lines.length && !lines[lines.length - 1]!.trim()) lines.pop(); + while (lines.length && !lines[0]!.trim()) lines.shift(); + return lines; +} + +/** Messages Git writes itself: a merge, `git revert`, and the `--fixup`/`--squash` autosquash markers. */ +export function isGitGenerated(firstLine: string): boolean { + return firstLine.startsWith('Merge ') || firstLine.startsWith('Revert "') || + /^(fixup|squash|amend)! /u.test(firstLine); +} + +/** Why an authored message is rejected, or undefined when it is acceptable. */ +export function messageProblem(raw: string): string | undefined { + const lines = cleanMessage(raw), first = lines[0] ?? ''; + if (!first.trim()) return 'The message is empty.'; + if (isGitGenerated(first)) return undefined; + if (lines.some(line => ATTRIBUTION.test(line))) return 'Remove attribution (Co-Authored-By, "Generated with …").'; + if (lines.length > 1) return 'Use a single line: no body and no trailers.'; + if (!CONVENTIONAL.test(first)) { + return `Use Conventional Commits: ()?!?: , with a lowercase type from ${TYPES.join(', ')}, and a scope of a-z, 0-9, ".", "_", "/" or "-" (no commas or spaces). Got: ${first}`; + } + if (first.length > MAX_LENGTH) return `Keep the line to ${MAX_LENGTH} characters; it has ${first.length}.`; + return undefined; +} + +export interface RangeCommit { + readonly sha: string; + readonly parents: number; + readonly authorEmail: string; + readonly message: string; +} + +export function authorProblem(email: string): string | undefined { + return AUTHOR_EMAILS.includes(email) ? undefined : + `Author email must be ${AUTHOR_EMAILS.join(' or ')}; got ${email || '(empty)'}.`; +} + +export function rangeProblems(commits: readonly RangeCommit[]): string[] { + return commits.flatMap(commit => { + // A merge's message is Git's own, whatever its text. + const problem = authorProblem(commit.authorEmail) ?? + (commit.parents > 1 ? undefined : messageProblem(commit.message)); + return problem ? [`${commit.sha.slice(0, 10)} ${cleanMessage(commit.message)[0] ?? ''}\n ${problem}`] : []; + }); +} + +export async function commitsIn(range: string, root: string): Promise { + const options = { cwd: root, maxBuffer: 1024 * 1024 * 64 }; + const { stdout } = await execFileAsync('git', ['log', '--format=%H', range, '--'], options); + const commits: RangeCommit[] = []; + // Read each message separately: user-controlled record separators cannot hide later commits. + for (const sha of stdout.trim().split('\n').filter(Boolean)) { + const { stdout: record } = await execFileAsync('git', + ['show', '--no-patch', '--format=%P%x00%ae%x00%B', sha], options); + const first = record.indexOf('\0'), second = record.indexOf('\0', first + 1); + if (first < 0 || second < 0) throw new Error(`Cannot read commit ${sha}.`); + commits.push({ sha, parents: record.slice(0, first).split(' ').filter(Boolean).length, + authorEmail: record.slice(first + 1, second), message: record.slice(second + 1) }); + } + return commits; +} + +export async function currentAuthorEmail(root: string): Promise { + const { stdout } = await execFileAsync('git', ['var', 'GIT_AUTHOR_IDENT'], { cwd: root }); + const match = /<([^<>]*)> \d+ [+-]\d+\s*$/u.exec(stdout); + if (!match) throw new Error('Cannot read the effective Git author identity.'); + return match[1]!; +} + +/** The hooks directory `--install` owns, inside the clone's common Git directory, so every worktree shares it. */ +export const HOOKS_DIRECTORY = 'commit-policy-hooks'; + +/** Runs the calling worktree's own tracked hook. It names no checker path, so it stays correct on every branch + * layout, and it lives outside `.git/hooks`, which an older branch's installer overwrites. */ +/** Marks a file this installer wrote into its hooks directory; nothing else there is ever overwritten. */ +export const DISPATCHER_MARKER = 'commit-policy-commit-msg-dispatcher'; +export const DISPATCHER = `#!/bin/sh +# ${HOOK_MARKER} ${DISPATCHER_MARKER}: run this worktree's tracked ${HOOK_SOURCE}. Installed explicitly. +root=$(git rev-parse --show-toplevel) || { echo "commit-msg: cannot find this worktree's top level; run git commit from inside the worktree." >&2; exit 1; } +hook="$root/${HOOK_SOURCE}" +[ -f "$hook" ] || [ ! -f "$root/${EARLIER_HOOK_SOURCE}" ] || hook="$root/${EARLIER_HOOK_SOURCE}" +[ -f "$hook" ] || { echo "commit-msg: $root/${HOOK_SOURCE} is missing; restore it (git checkout -- ${HOOK_SOURCE}), or skip once with git commit --no-verify." >&2; exit 1; } +exec sh "$hook" "$@" +`; + +const git = async (root: string, ...args: string[]) => (await execFileAsync('git', args, { cwd: root })).stdout.trim(); + +/** Point this clone's core.hooksPath at a dispatcher that runs each worktree's own tracked hook. Git then never reads + * `.git/hooks`, so a copy an older branch's installer writes there cannot replace the check; that installer also + * returns early once core.hooksPath is set. Leaves any core.hooksPath already configured (including the pre-push + * opt-in `.github/hooks`) and never disables a hook of the contributor's own in `.git/hooks`. */ +export async function installHook(root: string): Promise { + let common: string, configured: string | undefined; + try { + common = resolve(root, await git(root, 'rev-parse', '--git-common-dir')); + // `git config --get` fails when the key is unset; an explicitly empty value is a setting too, and is kept. + configured = await git(root, 'config', '--get', 'core.hooksPath').catch(() => undefined); + } catch { + return 'Not a Git checkout; no hook installed.'; + } + // Not `git rev-parse --git-path hooks`: that answers core.hooksPath once it is set. + const legacy = resolve(common, 'hooks'), directory = resolve(common, HOOKS_DIRECTORY), target = resolve(directory, 'commit-msg'); + // The opt-in from before the hooks moved under `.github/` names a directory this layout no longer has, so Git would run + // no hook at all. Carry the opt-in over to the directory that replaced it. + if (configured !== undefined && resolve(root, configured) === resolve(root, EARLIER_HOOKS_PATH) + && !existsSync(resolve(root, EARLIER_HOOKS_PATH)) && existsSync(resolve(root, OPT_IN_HOOKS_PATH))) { + await git(root, 'config', 'core.hooksPath', OPT_IN_HOOKS_PATH); + return `core.hooksPath was ${JSON.stringify(configured)}, which no longer exists; it is now ${JSON.stringify(OPT_IN_HOOKS_PATH)}.`; + } + if (configured !== undefined && (!configured || resolve(root, configured) !== directory)) return `core.hooksPath is ${JSON.stringify(configured)}; left unchanged (${HOOK_SOURCE} applies when that path is .github/hooks).`; + if (configured === undefined) { + const own: string[] = []; + for (const name of await readdir(legacy).catch(() => [] as string[])) { + if (name.endsWith('.sample')) continue; + const text = await readFile(resolve(legacy, name), 'utf8').catch(() => ''); + if (!(name === 'commit-msg' && text.includes(HOOK_MARKER))) own.push(name); + } + if (own.length) return `${legacy} has hooks of your own (${own.join(', ')}); left unchanged. Set core.hooksPath to run ${HOOK_SOURCE}.`; + } + const current = await readFile(target, 'utf8').catch(() => undefined); + if (current !== undefined && !current.includes(DISPATCHER_MARKER)) return `${target} is not this installer's dispatcher; left unchanged.`; + await mkdir(directory, { recursive: true }); + await writeFile(target, DISPATCHER); + await chmod(target, 0o755); + await git(root, 'config', 'core.hooksPath', directory); + // The copy an earlier install left in .git/hooks is no longer read; remove it so nothing looks installed twice. + const copy = resolve(legacy, 'commit-msg'); + if ((await readFile(copy, 'utf8').catch(() => '')).includes(HOOK_MARKER)) await rm(copy, { force: true }); + return `Installed the commit-msg hook: core.hooksPath is ${directory}.`; +} + +async function main(args: readonly string[]): Promise { + const root = resolve(import.meta.dirname, '../../..'); + if (args[0] === '--install' && args.length === 1) { + console.log(`commit-message: ${await installHook(root)}`); + return 0; + } + if (args[0] === '--range' && args.length === 2 && args[1]) { + const problems = rangeProblems(await commitsIn(args[1], root)); + if (!problems.length) return 0; + console.error(`Commit authors must be allowlisted; messages must be one Conventional Commits line with no attribution:\n${problems.join('\n')}\n` + + 'Reword them (git rebase -i, then "reword"), and see CONTRIBUTING.md#commits.'); + return 1; + } + if (args.length === 1 && args[0] && !args[0].startsWith('--')) { + const problem = authorProblem(await currentAuthorEmail(root)) ?? + messageProblem(await readFile(args[0], 'utf8')); + if (!problem) return 0; + console.error(`commit-msg: ${problem}\nSee CONTRIBUTING.md#commits. Skip once with git commit --no-verify; CI still checks.`); + return 1; + } + console.error('Usage: commit-message.mts | --range .. | --install'); + return 2; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { + process.exitCode = await main(process.argv.slice(2)); + } catch (error) { + console.error(`commit-message: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } +} diff --git a/.github/scripts/ci/commit-message.test.mts b/.github/scripts/ci/commit-message.test.mts new file mode 100644 index 00000000..fac84c1e --- /dev/null +++ b/.github/scripts/ci/commit-message.test.mts @@ -0,0 +1,204 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, resolve } from 'node:path'; +import { test } from 'node:test'; +import { AUTHOR_EMAILS, DISPATCHER_MARKER, HOOKS_DIRECTORY, MAX_LENGTH, TYPES, + authorProblem, cleanMessage, commitsIn, installHook, messageProblem, rangeProblems } from './commit-message.mts'; + +const allowed = 'juancfortunatti@gmail.com'; +const commit = (message: string, overrides: Partial = {}): RangeCommit => + ({ sha: 'a'.repeat(40), parents: 1, authorEmail: allowed, message, ...overrides }); +import type { RangeCommit } from './commit-message.mts'; + +test('one Conventional Commits line passes for every allowed type, scopes and breaking markers', () => { + assert.deepEqual(AUTHOR_EMAILS, [allowed]); + for (const type of TYPES) assert.equal(messageProblem(`${type}: update`), undefined); + for (const message of ['refactor(core): move reader\n', 'fix!: drop old reader\n', + 'ci(commit-message): check every commit', 'chore(deps/pnpm): bump lockfile\n\n\n']) { + assert.equal(messageProblem(message), undefined, message); + } +}); + +test('Git comment blocks, CRLF and trailing blank lines follow the reference cleanup', () => { + assert.deepEqual(cleanMessage('\nfeat: add x\n\n# Please enter a message\n# On branch main\n'), ['feat: add x']); + assert.equal(messageProblem('feat: add x\r\n\r\n# comment\r\n'), undefined); +}); + +test('bodies, trailers and all attribution variants fail', () => { + for (const body of ['Why it matters.', 'Signed-off-by: test ']) { + assert.match(messageProblem(`feat: add x\n\n${body}\n`) ?? '', /single line/u); + } + for (const text of ['Co-Authored-By: test ', 'co-authored-by: test', + 'GENERATED WITH a tool', 'generated by a tool', '🤖']) { + assert.match(messageProblem(`feat: ${text}`) ?? '', /attribution/u); + assert.match(messageProblem(`feat: add x\n\n${text}\n`) ?? '', /attribution/u); + } +}); + +test('nonconventional titles, invalid scopes and whitespace fail', () => { + for (const message of ['moved reader', 'Refactor(core): move', 'feat(Core): move', 'feat(a,b): move', + 'feat(a b): move', 'feat(): move', 'feat:move', 'feat: ', 'wip: things', 'feat: trailing space ', '']) { + assert.notEqual(messageProblem(message), undefined, JSON.stringify(message)); + } +}); + +test('150 characters pass and 151 fail', () => { + assert.equal(messageProblem(`feat: ${'x'.repeat(MAX_LENGTH - 6)}`), undefined); + assert.match(messageProblem(`feat: ${'x'.repeat(MAX_LENGTH - 5)}`) ?? '', /characters/u); +}); + +test('Git-generated messages preserve the reference exemptions', () => { + for (const message of ["Merge branch 'main' into feature\n", 'Revert "feat: add x"\n\nThis reverts commit abc.\n', + 'fixup! feat: add x\n', 'squash! feat: add x\n\nmore\n', 'amend! feat: add x\n\nmore\n']) { + assert.equal(messageProblem(message), undefined, message); + } +}); + +test('the author allowlist rejects foreign, empty, GitHub and near-match emails', () => { + assert.equal(authorProblem(allowed), undefined); + for (const email of ['', 'bot@example.com', 'noreply@github.com', `${allowed}.evil`, allowed.toUpperCase()]) { + assert.match(authorProblem(email) ?? '', /Author email/u, email); + } +}); + +test('ranges skip merge messages while enforcing authors on merges and generated messages', () => { + const problems = rangeProblems([commit('feat: fine'), commit('bad title', { sha: 'b'.repeat(40) }), + commit('Any merge text\n\nwith a body', { parents: 2 }), + commit('Any merge text', { parents: 2, authorEmail: 'bot@example.com' }), + commit('fixup! feat: fine', { authorEmail: 'bot@example.com' })]); + assert.equal(problems.length, 3); + assert.match(problems[0]!, /^bbbbbbbbbb bad title\n {2}Use Conventional Commits/u); + assert.match(problems[1]!, /Author email/u); + assert.match(problems[2]!, /Author email/u); +}); + +function fixture() { + const root = mkdtempSync(resolve(tmpdir(), 'commit-message-')); + const env = { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', + GIT_AUTHOR_NAME: 'test', GIT_AUTHOR_EMAIL: allowed, GIT_COMMITTER_NAME: 'test', GIT_COMMITTER_EMAIL: allowed }; + const git = (...args: string[]) => execFileSync('git', args, { cwd: root, env, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); + git('init', '-q'); + git('config', 'user.name', 'test'); + git('config', 'user.email', allowed); + for (const path of ['.github/hooks/commit-msg', '.github/scripts/ci/commit-message.mts']) { + mkdirSync(dirname(resolve(root, path)), { recursive: true }); + copyFileSync(resolve(import.meta.dirname, '../../..', path), resolve(root, path)); + } + chmodSync(resolve(root, '.github/hooks/commit-msg'), 0o755); + const tree = git('mktree'); + const makeCommit = (message: string, parents: string[] = [], author = allowed, committer = allowed) => + execFileSync('git', ['commit-tree', tree, ...parents.flatMap(parent => ['-p', parent]), '-m', message], + { cwd: root, env: { ...env, GIT_AUTHOR_EMAIL: author, GIT_COMMITTER_EMAIL: committer }, encoding: 'utf8' }).trim(); + const start = makeCommit('chore: start'); + git('update-ref', 'HEAD', start); + const checker = resolve(root, '.github/scripts/ci/commit-message.mts'); + const run = (...args: string[]) => execFileSync(process.execPath, [checker, ...args], { cwd: root, env, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }); + const hook = (message: string, author = allowed) => { + const file = resolve(root, 'message'); + writeFileSync(file, message); + return execFileSync('git', ['hook', 'run', 'commit-msg', '--', file], + { cwd: root, env: { ...env, GIT_AUTHOR_EMAIL: author }, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }); + }; + return { root, env, git, start, makeCommit, checker, run, hook }; +} + +test('real Git ranges use the original author, permit GitHub committers and preserve message separators', async () => { + const f = fixture(); + try { + const good = f.makeCommit('feat: good', [f.start], allowed, 'noreply@github.com'); + const bad = f.makeCommit('feat: bad\n\n\u001e\u001fhidden', [good], 'bot@example.com'); + const merge = f.makeCommit('arbitrary merge body\n\nmore', [good, bad]); + const commits = await commitsIn(`${f.start}..${merge}`, f.root); + assert.equal(commits.length, 3); + assert.equal(commits.find(c => c.sha === good)?.authorEmail, allowed); + assert.match(commits.find(c => c.sha === bad)?.message ?? '', /\u001e\u001fhidden/u); + const problems = rangeProblems(commits); + assert.equal(problems.length, 1); + assert.match(problems[0]!, /bot@example.com/u); + f.run('--range', `${f.start}..${good}`); + assert.throws(() => f.run('--range', `${f.start}..${merge}`), /Author email/u); + const foreignMerge = f.makeCommit('Merge branches', [good, bad], 'bot@example.com', 'noreply@github.com'); + assert.equal(rangeProblems(await commitsIn(`${merge}..${foreignMerge}`, f.root)).length, 1); + } finally { rmSync(f.root, { recursive: true, force: true }); } +}); + +test('invalid ranges and CLI arguments fail closed', () => { + const f = fixture(); + try { + assert.throws(() => f.run('--range', 'missing-ref..HEAD'), /unknown revision|ambiguous argument|bad revision/u); + assert.throws(() => f.run('--unknown'), /Usage/u); + assert.throws(() => f.run('missing-file'), /ENOENT/u); + } finally { rmSync(f.root, { recursive: true, force: true }); } +}); + +test('the installed hook rejects bad messages and author overrides, and reinstalls idempotently', async () => { + const f = fixture(); + try { + assert.match(await installHook(f.root), /Installed/u); + assert.equal(f.git('config', '--get', 'core.hooksPath'), resolve(f.root, '.git', HOOKS_DIRECTORY)); + assert.throws(() => f.hook('Bad message'), /Conventional Commits/u); + assert.throws(() => f.hook('chore: fine\n\nbody'), /single line/u); + assert.throws(() => f.hook('chore: generated by a tool'), /attribution/u); + assert.throws(() => f.hook('chore: fine', 'bot@example.com'), /Author email/u); + assert.throws(() => f.hook('Merge branch main', 'bot@example.com'), /Author email/u); + f.hook('chore: fine'); + assert.match(await installHook(f.root), /Installed/u); + assert.throws(() => f.hook('Bad message'), /Conventional Commits/u); + } finally { rmSync(f.root, { recursive: true, force: true }); } +}); + +test('the installer preserves user hooks, custom paths, empty settings and foreign dispatchers', async () => { + const f = fixture(); + try { + const own = resolve(f.root, '.git/hooks/pre-commit'); + writeFileSync(own, '#!/bin/sh\necho mine\n', { mode: 0o755 }); + assert.match(await installHook(f.root), /hooks of your own/u); + assert.throws(() => f.git('config', '--get', 'core.hooksPath')); + rmSync(own); + for (const path of ['custom-hooks', '']) { + f.git('config', 'core.hooksPath', path); + assert.match(await installHook(f.root), /left unchanged/u); + assert.equal(f.git('config', '--get', 'core.hooksPath'), path); + } + f.git('config', '--unset', 'core.hooksPath'); + const target = resolve(f.root, '.git', HOOKS_DIRECTORY, 'commit-msg'); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, '#!/bin/sh\necho mine\n'); + assert.match(await installHook(f.root), /not this installer's dispatcher/u); + assert.equal(readFileSync(target, 'utf8'), '#!/bin/sh\necho mine\n'); + } finally { rmSync(f.root, { recursive: true, force: true }); } +}); + +test('direct opt-in hooks and shared dispatchers fail when tracked inputs disappear', async () => { + const f = fixture(); + try { + f.git('config', 'core.hooksPath', '.github/hooks'); + f.hook('chore: fine'); + rmSync(f.checker); + assert.throws(() => f.hook('chore: fine'), /commit-message\.mts is missing/u); + copyFileSync(resolve(import.meta.dirname, 'commit-message.mts'), f.checker); + f.git('config', '--unset', 'core.hooksPath'); + assert.match(await installHook(f.root), /Installed/u); + assert.match(readFileSync(resolve(f.root, '.git', HOOKS_DIRECTORY, 'commit-msg'), 'utf8'), new RegExp(DISPATCHER_MARKER, 'u')); + rmSync(resolve(f.root, '.github/hooks/commit-msg')); + assert.throws(() => f.hook('chore: fine'), /commit-msg is missing/u); + } finally { rmSync(f.root, { recursive: true, force: true }); } +}); + +test('the dispatcher follows the calling linked worktree and does not capture its install location', async () => { + const f = fixture(); + try { + await installHook(f.root); + const linked = resolve(f.root, 'linked'); + f.git('worktree', 'add', '-q', '--detach', linked, f.start); + mkdirSync(resolve(linked, '.github/hooks'), { recursive: true }); + writeFileSync(resolve(linked, '.github/hooks/commit-msg'), '#!/bin/sh\necho linked-worktree >&2\nexit 7\n'); + const message = resolve(linked, 'message'); + writeFileSync(message, 'chore: fine'); + assert.throws(() => execFileSync('git', ['hook', 'run', 'commit-msg', '--', message], + { cwd: linked, env: f.env, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }), /linked-worktree/u); + f.hook('chore: fine'); + } finally { rmSync(f.root, { recursive: true, force: true }); } +}); diff --git a/.github/scripts/ci/tsconfig.json b/.github/scripts/ci/tsconfig.json new file mode 100644 index 00000000..f9b2434e --- /dev/null +++ b/.github/scripts/ci/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "skipLibCheck": true, + "types": [ + "node" + ] + }, + "include": [ + "*.mts" + ] +} diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index 078de4df..8d2ec54a 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -2,46 +2,59 @@ name: Deploy Website on: push: - branches: [main] + branches: + - main paths: - - 'website/**' - - '.github/workflows/deploy-website.yml' + - "website/**" + - ".github/workflows/deploy-website.yml" + workflow_dispatch: + +permissions: + contents: read + id-token: write + pages: write + +concurrency: + group: github-pages + cancel-in-progress: false jobs: deploy: name: deploy spawnfile.com timeout-minutes: 15 runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} steps: - - uses: actions/checkout@v4 + - name: Check out + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - - uses: actions/setup-node@v4 + - name: Set up Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: 22 cache: npm cache-dependency-path: website/package-lock.json - name: Install dependencies - run: npm ci - working-directory: website - - - name: Audit runtime dependencies - run: npm audit --omit=dev --audit-level=high working-directory: website + run: npm ci - name: Build - run: npm run build working-directory: website + run: npm run build - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v4 + - name: Configure GitHub Pages + uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: us-east-2 + enablement: true - - name: Deploy to S3 - working-directory: website - run: | - aws s3 sync dist/ s3://spawnfile.ai/ --delete - aws s3 sync dist/ s3://spawnfile.com/ --delete + - name: Upload GitHub Pages artifact + uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b + with: + path: website/dist + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 559e0398..9138bb6a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,10 +9,37 @@ permissions: contents: read jobs: + commit-message: + name: Commit messages and author allowlist + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - name: Check out pull request head with full history + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + - name: Set up Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '22' + - name: Check every pull request commit before install + run: node .github/scripts/ci/commit-message.mts --range "origin/$GITHUB_BASE_REF..HEAD" + - name: Test commit policy + run: | + mkdir -p .tmp-commit-check + TMPDIR="$PWD/.tmp-commit-check" node --test .github/scripts/ci/commit-message.test.mts + root: name: compiler + runtime tests timeout-minutes: 30 runs-on: ubuntu-latest + env: + TMPDIR: ${{ github.workspace }}/.tmp + DAIMON_SOURCE_ROOT: ${{ github.workspace }}/.tmp/daimon-source steps: - name: Check out Spawnfile uses: actions/checkout@v4 @@ -25,7 +52,25 @@ jobs: cache-dependency-path: package-lock.json - name: Install dependencies - run: npm ci + run: mkdir -p "$TMPDIR" && npm ci + + - name: Resolve exact Daimon recovery fixture revision + id: daimon-fixture + run: | + node --input-type=module <<'NODE' >> "$GITHUB_OUTPUT" + import { readFileSync } from 'node:fs'; + import { parse } from 'yaml'; + const ref = parse(readFileSync('runtimes.yaml', 'utf8')).runtimes.daimon.ref; + if (!/^[a-f0-9]{40}$/.test(ref)) throw Error('Daimon recovery requires a full commit pin'); + console.log(`ref=${ref}`); + NODE + + - name: Check out pinned Daimon recovery fixture + uses: actions/checkout@v4 + with: + repository: noopolis/daimon-harness + ref: ${{ steps.daimon-fixture.outputs.ref }} + path: .tmp/daimon-source - name: Audit runtime dependencies run: npm audit --omit=dev --audit-level=high diff --git a/.gitignore b/.gitignore index 810fe823..de99bf61 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ node_modules .moltnet .worktree-bootstrap.json .local-daimon-runtime-identity.json +.tmp/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eac35b36..f921b850 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -69,3 +69,30 @@ When adding a runtime, also update `runtimes.yaml` with the pinned version and s Use conventional commits: `feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:`. Do not add co-author attributions, sign-off lines, or AI credit to commits. + +## Commits + +Every authored commit must be one Conventional Commits line of at most 150 +characters: `type(scope)!: summary`, with scope and `!` optional. Allowed types: +feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert. +No body, trailers, Co-Authored-By, "generated with/by", or robot emoji. +Git-generated Merge, Revert, fixup!, squash! and amend! messages are accepted; +merge commits skip message validation. All commits, including merges, must have +an author email of `juancfortunatti@gmail.com`. Committers may be GitHub +`noreply@github.com`; the allowlist checks the author, not the committer. + +To opt into the local hook, from a fresh clone or any linked worktree: + +```sh +cd "$(git rev-parse --show-toplevel)" +node .github/scripts/ci/commit-message.mts --install +``` + +Requires Node 22.19 or later, with no package install. The installer preserves +existing hooks and custom `core.hooksPath`; read its result. If it leaves an +existing setup unchanged, integrate `.github/hooks/commit-msg` into that setup +instead of replacing it. The shared dispatcher runs each worktree's own tracked +hook and fails if it is missing. Installation is opt-in and never runs on install. +The hook checks the effective Git author, including `GIT_AUTHOR_EMAIL` overrides. +`--no-verify` skips the local hook; CI still checks every PR commit in +`origin/$GITHUB_BASE_REF..HEAD` with full history, before dependency installation. diff --git a/fixtures/support/fakeMoltnetCli.ts b/fixtures/support/fakeMoltnetCli.ts index 1a8e428d..765852d5 100644 --- a/fixtures/support/fakeMoltnetCli.ts +++ b/fixtures/support/fakeMoltnetCli.ts @@ -22,7 +22,7 @@ export const createFakeMoltnetCli = async ( const directory = await mkdtemp(path.join(os.tmpdir(), "spawnfile-fake-moltnet-cli-")); register?.(directory); - const cliPath = path.join(directory, "moltnet"); + const cliPath = path.join(directory, "moltnet.cjs"); await writeUtf8File( cliPath, [ diff --git a/package.json b/package.json index 16912d10..6f369f77 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "test:product-state-volume": "node --experimental-strip-types scripts/product-state-volume-integration.test.ts", "test:boundaries": "vitest run --coverage.enabled=false src/ownership/rootOwnershipBoundary.test.ts src/deployment/providerRuntimeBoundary.test.ts src/ownership/mnemePublicImportBoundary.test.ts src/target/containerBundleArchive.test.ts", "test:causal-conformance": "tsx src/ledger/causalConformanceCli.ts", - "typecheck": "tsc --project tsconfig.json --noEmit && tsc --project scripts/tsconfig.json", + "typecheck": "tsc --project tsconfig.json --noEmit && tsc --project scripts/tsconfig.json && tsc --project .github/scripts/ci/tsconfig.json", "test:e2e:distribution-image": "tsx src/e2e/cli.ts distribution-image", "test:e2e:distribution-roundtrip": "tsx src/e2e/cli.ts distribution-roundtrip", "test:e2e:daimon-org": "tsx src/e2e/cli.ts daimon-org", diff --git a/runtime-images/daimon/Dockerfile b/runtime-images/daimon/Dockerfile index 8345cd59..4d47c9eb 100644 --- a/runtime-images/daimon/Dockerfile +++ b/runtime-images/daimon/Dockerfile @@ -122,33 +122,55 @@ ARG CODEX_CLI_SHA256 ARG TARGETARCH ARG RUNTIME_ROOT=/opt/spawnfile/runtime-installs/daimon -RUN test -n "${GROK_CLI_VERSION}" \ +RUN echo "Verifying: required input GROK_CLI_VERSION" \ + && test -n "${GROK_CLI_VERSION}" \ + && echo "Verifying: required input GROK_CLI_URL" \ && test -n "${GROK_CLI_URL}" \ + && echo "Verifying: required input GROK_CLI_SHA256" \ && test -n "${GROK_CLI_SHA256}" \ + && echo "Verifying: required input AGY_CLI_VERSION" \ && test -n "${AGY_CLI_VERSION}" \ + && echo "Verifying: required input AGY_CLI_URL" \ && test -n "${AGY_CLI_URL}" \ + && echo "Verifying: required input AGY_CLI_SHA512" \ && test -n "${AGY_CLI_SHA512}" \ + && echo "Verifying: required input AGY_CLI_SHA256" \ && test -n "${AGY_CLI_SHA256}" \ + && echo "Verifying: required input DAIMON_CAPABILITY_RECEIPT_BASE64" \ && test -n "${DAIMON_CAPABILITY_RECEIPT_BASE64}" \ + && echo "Verifying: required input DAIMON_MANIFEST_SHA256" \ && test -n "${DAIMON_MANIFEST_SHA256}" \ + && echo "Verifying: required input DAIMON_PACKAGE_SHA256" \ && test -n "${DAIMON_PACKAGE_SHA256}" \ + && echo "Verifying: required input DAIMON_SOURCE_SHA256" \ && test -n "${DAIMON_SOURCE_SHA256}" \ + && echo "Verifying: supported dependency mode" \ && { test "${DAIMON_DEPENDENCY_MODE}" = registry || test "${DAIMON_DEPENDENCY_MODE}" = offline-bundle; } \ + && echo "Verifying: required input CODEX_CLI_SHA256" \ && test -n "${CODEX_CLI_SHA256}" \ + && echo "Verifying: required input TARGETARCH" \ && test -n "${TARGETARCH}" \ + && echo "Verifying: Codex launcher exists and is executable" \ && test -x ${RUNTIME_ROOT}/node_modules/@openai/codex/bin/codex.js \ + && echo "Verifying: Daimon runtime entrypoint exists" \ && test -f ${RUNTIME_ROOT}/node_modules/@noopolis/daimon/dist/runtime/cli.js \ + && echo "Verifying: engine-broker target architecture" \ && case "${TARGETARCH}" in \ amd64) broker_arch=x64 ;; \ arm64) broker_arch=arm64 ;; \ *) echo "Unsupported Daimon engine-broker architecture: ${TARGETARCH}" >&2; exit 1 ;; \ esac \ && package_manifest=${RUNTIME_ROOT}/node_modules/@noopolis/daimon/dist/runtime/contract-manifest.json \ + && echo "Verifying: package contract manifest SHA-256" \ && test "$(sha256sum "${package_manifest}" | awk '{print "sha256:" $1}')" = "${DAIMON_MANIFEST_SHA256}" \ + && echo "Verifying: engine-broker manifest SHA-256 pin" \ && broker_sha="$(node -e 'const m=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).grokEngineBroker; const v=m?.artifacts?.[process.argv[2]+"Sha256"]; if(typeof v!=="string"||!/^[a-f0-9]{64}$/.test(v))process.exit(1); process.stdout.write(v)' "${package_manifest}" "${broker_arch}")" \ + && echo "Verifying: Grok CLI pins match the contract manifest" \ && node -e 'const m=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).grokEngineBroker, a=m?.grokCliArtifacts?.[process.argv[2]]; if(!a||m.grokCliVersion!==process.argv[3]||a.url!==process.argv[4]||a.sha256!==process.argv[5].replace(/^sha256:/,"")){console.error("Grok CLI pin does not match the Daimon contract manifest ("+(m?.grokCliVersion)+")");process.exit(1)}' "${package_manifest}" "${broker_arch}" "${GROK_CLI_VERSION}" "${GROK_CLI_URL}" "${GROK_CLI_SHA256}" \ && broker_source=${RUNTIME_ROOT}/node_modules/@noopolis/daimon/dist/runtime/native/daimon-engine-broker \ + && echo "Verifying: packaged engine broker exists and is executable" \ && test -x "${broker_source}" \ + && echo "Verifying: packaged engine-broker SHA-256" \ && test "$(sha256sum "${broker_source}" | awk '{print $1}')" = "${broker_sha}" \ && install -m 0555 "${broker_source}" ${RUNTIME_ROOT}/bin/daimon-engine-broker \ && printf '%s\n' '#!/usr/bin/env node' 'import "../node_modules/@openai/codex/bin/codex.js";' > ${RUNTIME_ROOT}/bin/codex \ @@ -159,15 +181,25 @@ RUN test -n "${GROK_CLI_VERSION}" \ && cp /tmp/source-inputs.json ${RUNTIME_ROOT}/source-inputs.json \ && printf '%s' "${DAIMON_CAPABILITY_RECEIPT_BASE64}" | base64 -d > ${RUNTIME_ROOT}/capability-receipt.json \ && expected_manifest="$(cat ${RUNTIME_ROOT}/contract-manifest.sha256)" \ + && echo "Verifying: contract digest file matches the manifest pin" \ && test "${expected_manifest}" = "${DAIMON_MANIFEST_SHA256}" \ + && echo "Verifying: installed contract manifest SHA-256" \ && test "$(sha256sum ${RUNTIME_ROOT}/contract-manifest.json | awk '{print "sha256:" $1}')" = "${expected_manifest}" \ - && node -e 'const fs=require("fs"); const raw=fs.readFileSync(process.argv[1],"utf8"); const c=v=>Array.isArray(v)?"["+v.map(c).join(",")+"]":v&&typeof v==="object"?"{"+Object.keys(v).sort().map(k=>JSON.stringify(k)+":"+c(v[k])).join(",")+"}":JSON.stringify(v); const m=JSON.parse(raw),s=["agy","codex","grok"],w=["manual","message","schedule","external"],e={codex:["codex-auth",".daimon-inbound/codex-auth",".codex/auth.json"]},a=m.agySubscriptionRealm,g=m.grokSubscriptionRealm,d=m.deliverySemantics; if(raw!==c(m)+"\n"||m.version!=="noopolis.daimon.runtime-contract-manifest.v4"||m.organizationRuntimeConfigV2Schema?.$id!=="noopolis.daimon.organization-runtime.v2"||JSON.stringify(m.supportedEngineKinds)!==JSON.stringify(s)||JSON.stringify(m.wakeAcceptanceTypes)!==JSON.stringify(w)||d?.terminalReceiptHorizon!==2048||d?.recovery!=="at-least-once-with-stable-wake-id"||d?.externalEffectsExactlyOnce!==false||!m.engineCredentialMaterial||Object.keys(m.engineCredentialMaterial).length!==1||Object.entries(e).some(([k,[s,i,d]])=>{const x=m.engineCredentialMaterial[k];return !x||x.sourceSlot!==s||x.sourceRelativePath!==i||x.destinationRelativePath!==d||x.directoryMode!==448||x.fileMode!==384;})||!a||a.durableMountPath!=="/var/lib/spawnfile/daimon/agy-subscription-realm"||a.unlockMountPath!=="/var/lib/spawnfile/daimon/agy-unlock-secret"||a.unlockSourceSlot!=="agy-unlock-secret"||a.directoryMode!==448||a.fileMode!==384||a.maxUnlockBytes!==4096||!g||g.durableMountPath!=="/var/lib/spawnfile/daimon/grok-subscription-realm"||g.bootstrapMountPath!=="/var/lib/spawnfile/daimon/grok-bootstrap-auth"||g.bootstrapSourceSlot!=="grok-auth"||g.agentCredentialRelativePath!==".grok/auth.json"||g.directoryMode!==448||g.fileMode!==384||g.maxCredentialBytes!==65536)process.exit(1)' ${RUNTIME_ROOT}/contract-manifest.json \ + && echo "Verifying: canonical contract manifest v5 and supported semantics" \ + && node -e 'const fs=require("fs"); const raw=fs.readFileSync(process.argv[1],"utf8"); const c=v=>Array.isArray(v)?"["+v.map(c).join(",")+"]":v&&typeof v==="object"?"{"+Object.keys(v).sort().map(k=>JSON.stringify(k)+":"+c(v[k])).join(",")+"}":JSON.stringify(v); const m=JSON.parse(raw),s=["agy","codex","grok"],w=["manual","message","schedule","external"],e={codex:["codex-auth",".daimon-inbound/codex-auth",".codex/auth.json"]},a=m.agySubscriptionRealm,g=m.grokSubscriptionRealm,d=m.deliverySemantics; if(raw!==c(m)+"\n"||m.version!=="noopolis.daimon.runtime-contract-manifest.v5"||m.organizationRuntimeConfigV2Schema?.$id!=="noopolis.daimon.organization-runtime.v2"||JSON.stringify(m.supportedEngineKinds)!==JSON.stringify(s)||JSON.stringify(m.wakeAcceptanceTypes)!==JSON.stringify(w)||d?.terminalReceiptHorizon!==2048||d?.recovery!=="at-least-once-with-stable-wake-id"||d?.externalEffectsExactlyOnce!==false||!m.engineCredentialMaterial||Object.keys(m.engineCredentialMaterial).length!==1||Object.entries(e).some(([k,[s,i,d]])=>{const x=m.engineCredentialMaterial[k];return !x||x.sourceSlot!==s||x.sourceRelativePath!==i||x.destinationRelativePath!==d||x.directoryMode!==448||x.fileMode!==384;})||!a||a.durableMountPath!=="/var/lib/spawnfile/daimon/agy-subscription-realm"||a.unlockMountPath!=="/var/lib/spawnfile/daimon/agy-unlock-secret"||a.unlockSourceSlot!=="agy-unlock-secret"||a.directoryMode!==448||a.fileMode!==384||a.maxUnlockBytes!==4096||!g||g.durableMountPath!=="/var/lib/spawnfile/daimon/grok-subscription-realm"||g.bootstrapMountPath!=="/var/lib/spawnfile/daimon/grok-bootstrap-auth"||g.bootstrapSourceSlot!=="grok-auth"||g.agentCredentialRelativePath!==".grok/auth.json"||g.directoryMode!==448||g.fileMode!==384||g.maxCredentialBytes!==65536)process.exit(1)' ${RUNTIME_ROOT}/contract-manifest.json \ + && echo "Verifying: capability receipt matches build inputs" \ && node -e 'const fs=require("fs"); const r=JSON.parse(fs.readFileSync(process.argv[1])); const x={codex:process.argv[5],grok:process.argv[6],agy:process.argv[7]},a=r.provenance?.agy?.archive,g=r.provenance?.grok?.executable,z=process.argv[14],i=JSON.parse(fs.readFileSync(process.argv[15],"utf8")); if(r.version!=="spawnfile.daimon-runtime-capability-receipt.v1"||r.architecture!==process.argv[2]||r.manifest_sha256!==process.argv[3]||r.daimon?.package_sha256!==process.argv[4]||r.daimon?.source_sha256!==process.argv[8]||Object.entries(x).some(([k,v])=>r.engines?.[k]?.executable_sha256!==v)||a?.format!=="tar.gz"||a?.version!==process.argv[9]||a?.url!==process.argv[10]||a?.sha512!==process.argv[11]||g?.version!==process.argv[12]||g?.url!==process.argv[13]||g?.sha256!==x.grok||(z!=="none"&&(r.daimon?.source_inputs?.dependencies?.runtime_archive_sha256!==z||JSON.stringify(i)!==JSON.stringify(r.daimon.source_inputs))))process.exit(1)' ${RUNTIME_ROOT}/capability-receipt.json "${TARGETARCH}" "${expected_manifest}" "${DAIMON_PACKAGE_SHA256}" "${CODEX_CLI_SHA256}" "sha256:${GROK_CLI_SHA256#sha256:}" "sha256:${AGY_CLI_SHA256#sha256:}" "${DAIMON_SOURCE_SHA256}" "${AGY_CLI_VERSION}" "${AGY_CLI_URL}" "sha512:${AGY_CLI_SHA512#sha512:}" "${GROK_CLI_VERSION}" "${GROK_CLI_URL}" "${DAIMON_DEPENDENCY_ARCHIVE_SHA256}" /tmp/source-inputs.json \ + && echo "Verifying: installed Codex CLI SHA-256" \ && test "$(sha256sum ${RUNTIME_ROOT}/node_modules/@openai/codex/bin/codex.js | awk '{print "sha256:" $1}')" = "${CODEX_CLI_SHA256}" \ + && echo "Verifying: installed Grok CLI SHA-256" \ && test "$(sha256sum ${RUNTIME_ROOT}/bin/grok | awk '{print "sha256:" $1}')" = "sha256:${GROK_CLI_SHA256#sha256:}" \ + && echo "Verifying: installed engine-broker SHA-256" \ && test "$(sha256sum ${RUNTIME_ROOT}/bin/daimon-engine-broker | awk '{print $1}')" = "${broker_sha}" \ + && echo "Verifying: installed AGY CLI SHA-256" \ && test "$(sha256sum ${RUNTIME_ROOT}/bin/agy | awk '{print "sha256:" $1}')" = "sha256:${AGY_CLI_SHA256#sha256:}" \ + && echo "Verifying: runtime symlink containment" \ && node -e 'const fs=require("fs"),path=require("path"),root=path.resolve(process.argv[1]);let count=0;const walk=d=>{for(const n of fs.readdirSync(d)){const p=path.join(d,n),s=fs.lstatSync(p);if(s.isDirectory())walk(p);else if(s.isSymbolicLink()){if(++count>4096)throw Error("too many runtime links");const l=fs.readlinkSync(p);if(path.isAbsolute(l))throw Error("absolute runtime link");const r=fs.realpathSync(p);if(!r.startsWith(root+path.sep))throw Error("runtime link escape");const t=fs.statSync(p);if(!t.isFile()||t.dev!==fs.statSync(root).dev)throw Error("unsafe runtime link target");}}};walk(root)' ${RUNTIME_ROOT} \ + && echo "Verifying: installed Daimon package manifest exists" \ && test -f ${RUNTIME_ROOT}/node_modules/@noopolis/daimon/package.json FROM scratch diff --git a/runtime-images/daimon/publish-inputs.json b/runtime-images/daimon/publish-inputs.json index 3aef666d..b78fe993 100644 --- a/runtime-images/daimon/publish-inputs.json +++ b/runtime-images/daimon/publish-inputs.json @@ -19,12 +19,15 @@ "version": "0.142.3" }, "daimon": { - "commit": "fee115e24b8cdfa635f22235950c20a4528fe1f9", + "commit": "0b3bc7cb2d5b92d4eaf222066c3817191190be7e", "package_version": "0.2.0", "source": "https://github.com/noopolis/daimon-harness.git" }, "image": "noopolis/spawnfile-runtime-daimon", - "platforms": ["amd64", "arm64"], + "platforms": [ + "amd64", + "arm64" + ], "registry": "docker.io", "version": "spawnfile.daimon-runtime-publish-inputs.v1" } diff --git a/runtime-images/training/AGENTS.md b/runtime-images/training/AGENTS.md index 7baf943a..ac58968b 100644 --- a/runtime-images/training/AGENTS.md +++ b/runtime-images/training/AGENTS.md @@ -13,7 +13,7 @@ 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, -Bookworm's pinned `libfaketime=0.9.10-2.1`, the baked `2000`/`2100`/`2200` Daimon identities, +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 @@ -27,7 +27,7 @@ symlink `/usr/local/lib/daimon/libfaketime.so.1`. Never set image-wide 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.v3` runs the +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 baked because that container's root filesystem is read-only, so its entrypoint diff --git a/runtime-images/training/Dockerfile b/runtime-images/training/Dockerfile index 866367a3..785368bb 100644 --- a/runtime-images/training/Dockerfile +++ b/runtime-images/training/Dockerfile @@ -24,8 +24,8 @@ RUN . /etc/os-release && test "$VERSION_CODENAME" = bookworm \ # 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 -# build context, so it precedes dependency and source layers. -RUN for fixed_uid in 2000 2100 2200; do \ +# build context, so it sits first: it is the least frequently changing layer. +RUN for fixed_uid in 2000 2100 2200 2201 2202 2203 2204 2205 2206 2207; do \ groupadd -K GID_MIN=1 --gid "$fixed_uid" "daimon-$fixed_uid" \ && useradd -K UID_MIN=1 --no-create-home --no-log-init --uid "$fixed_uid" --gid "$fixed_uid" \ --home-dir /nonexistent --shell /usr/sbin/nologin "daimon-$fixed_uid"; \ @@ -38,7 +38,7 @@ RUN for fixed_uid in 2000 2100 2200; do \ # CAP_DAC_OVERRIDE there cannot override an inode owned by real uid 0; and a fresh bind of the parent # re-exposes this same directory rather than the bytes under it. Nothing at runtime can widen it, # because the container root is read-only — the slot provisioning asserts the mode and refuses otherwise. -RUN mkdir -p /run/training/inputs /run/training/output \ +RUN mkdir -p /run/training/inputs /run/training/output /run/training/slots \ && chown 0:0 /run/training /run/training/output && chmod 0755 /run/training /run/training/output \ && chown 0:2000 /run/training/inputs && chmod 0750 /run/training/inputs diff --git a/runtimes.yaml b/runtimes.yaml index 17778623..186f2ad5 100644 --- a/runtimes.yaml +++ b/runtimes.yaml @@ -13,17 +13,17 @@ runtimes: daimon: remote: git@github.com:noopolis/daimon-harness.git - ref: 834955934e102349335362c37524261625e38dbb + ref: 0b3bc7cb2d5b92d4eaf222066c3817191190be7e default_branch: main install: kind: container_image image: noopolis/spawnfile-runtime-daimon - tag: 0.2.0-8349559 - digest: sha256:85875f67af630e8b4e667a72f20f0b491940cfb3feb7fe132a13a84678147747 + tag: 0.2.0-0b3bc7c + digest: sha256:d4389f1cb99a8d2bf13134737ae058c0e48019d465bd35fbb250d7cde138b836 capability_receipts: - amd64: sha256:4714bbf377acdb098fcf90c0c91e55494f0996fca77e28cd599b6a72c7cac2dc - arm64: sha256:f48018b729b984fde30af433607087c2f72019aad59ccf26d2641f18757c2dc6 - contract_manifest_sha256: sha256:f7bdd283c420cb6685d32fb556bfb1dcf48f756b3a5ca5ae61e96950152a5cc0 + amd64: sha256:9db4f4d5b62c505a445429da9ae8cf8bce21590bd1b67f449d17e427934a5421 + arm64: sha256:8536fc5543378a8617a99bbdbddd0b6e909a6c0d4ed5f9bb5805d501a17ca90c + contract_manifest_sha256: sha256:e39551277c6727c8807b1247e759f57addcee3f17f1405ebc927179647d64de6 status: active openclaw: diff --git a/scripts/build-local-daimon-runtime.test.ts b/scripts/build-local-daimon-runtime.test.ts index 3da558cc..55234380 100644 --- a/scripts/build-local-daimon-runtime.test.ts +++ b/scripts/build-local-daimon-runtime.test.ts @@ -1,8 +1,10 @@ import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; import { mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import test from "node:test"; +import { fileURLToPath } from "node:url"; import { assertPinnedGrokCli, @@ -377,3 +379,21 @@ test("the runtime image derives broker and Grok pins from the attested Daimon ma assert.match(dockerfile, /grokCliArtifacts\?\.\[process\.argv\[2\]\]/u); assert.match(dockerfile, /m\.grokCliVersion!==process\.argv\[3\]/u); }); + +test("Dockerfile contract validation accepts the vendored manifest and labels each check", () => { + const dockerfile = readFileSync(new URL("../runtime-images/daimon/Dockerfile", import.meta.url), "utf8"); + const code = /node -e '([^']*const raw=fs.readFileSync[^']*)'/u.exec(dockerfile)?.[1]; + assert.ok(code, "missing contract validator"); + const manifest = new URL("../src/runtime/daimon/contract-manifest.json", import.meta.url); + const root = mkdtempSync(path.join(os.tmpdir(), "daimon-contract-check-")); + try { + const run = (file: string): number | null => spawnSync(process.execPath, ["-e", code, file]).status; + assert.equal(run(fileURLToPath(manifest)), 0, "Dockerfile rejects the pinned contract"); + for (const [before, after] of [['"version":"noopolis.daimon.runtime-contract-manifest.v5"', '"version":"noopolis.daimon.runtime-contract-manifest.v3"'], ['"terminalReceiptHorizon":2048', '"terminalReceiptHorizon":0']]) { + writeFileSync(path.join(root, "invalid.json"), readFileSync(manifest, "utf8").replace(before!, after!)); + assert.equal(run(path.join(root, "invalid.json")), 1, `invalid contract must fail: ${after}`); + } + const lines = dockerfile.slice(dockerfile.indexOf("FROM daimon_${DAIMON_DEPENDENCY_MODE} AS build")).split("\n"); + for (const [i, line] of lines.entries()) if (/^ && (test |\{ test |node -e |broker_sha=|case )/u.test(line)) assert.match(lines[i - 1] ?? "", /echo "Verifying: /u, line); + } finally { rmSync(root, { recursive: true, force: true }); } +}); diff --git a/scripts/vendor-daimon-grok-contract.ts b/scripts/vendor-daimon-grok-contract.ts index fe63b22e..335f8859 100644 --- a/scripts/vendor-daimon-grok-contract.ts +++ b/scripts/vendor-daimon-grok-contract.ts @@ -25,7 +25,7 @@ export const PROFILE_SAMPLE_DENY_PATHS: readonly (readonly string[])[] = [ type DaimonModules = { contractManifestArtifacts: () => { digest: Buffer; manifest: Buffer }; - renderGrokBrokerWorkerConfig: (policy: { model: string; reasoningEffort: string }, variant?: "unclocked" | "clocked") => string; + renderGrokBrokerWorkerConfig: (policy: { model: string; reasoningEffort: string }, variant?: "unclocked" | "clocked", slot?: number) => string; renderGrokWorkerSandboxProfile: (denyPaths: readonly string[]) => string; }; @@ -46,8 +46,8 @@ const loadDaimon = async (daimonDirectory: string): Promise => { export const renderVendoredWorkerModule = (daimon: Pick): string => { const configs = Object.fromEntries(models.map((model) => [model, Object.fromEntries(efforts.map((reasoningEffort) => [reasoningEffort, daimon.renderGrokBrokerWorkerConfig({ model, reasoningEffort })]))])); - const clockedConfigs = Object.fromEntries(models.map((model) => [model, Object.fromEntries(efforts.map((reasoningEffort) => - [reasoningEffort, daimon.renderGrokBrokerWorkerConfig({ model, reasoningEffort }, "clocked")]))])); + const clocked = Object.fromEntries(models.map((model) => [model, Object.fromEntries(efforts.map((reasoningEffort) => + [reasoningEffort, daimon.renderGrokBrokerWorkerConfig({ model, reasoningEffort }, "clocked", 0)]))])); const samples = PROFILE_SAMPLE_DENY_PATHS.map((denyPaths) => ({ bytes: daimon.renderGrokWorkerSandboxProfile(denyPaths), denyPaths })); return [ "/* v8 ignore file -- generated data module */", @@ -56,8 +56,7 @@ export const renderVendoredWorkerModule = (daimon: Pick","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 v3; the broker atomically publishes the offset to `/run/daimon-engine-broker/task-clock/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 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 @@ -729,7 +729,8 @@ harness MUST NOT itself run under libfaketime or receive the Mneme offset variab 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 have exactly one registration; turns cannot overlap. +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. diff --git a/specs/TRAINING_CONTAINERS.md b/specs/TRAINING_CONTAINERS.md index 38306fea..31be2e69 100644 --- a/specs/TRAINING_CONTAINERS.md +++ b/specs/TRAINING_CONTAINERS.md @@ -6,6 +6,25 @@ validates declarations, mounts explicit inputs, forwards output and supervises container termination. No Docker socket enters the container. Dry-run remains a host-only estimate and does not use Docker or model authentication. +Brokered training uses `spawnfile.training-container.v4` with `slots` from 1 to 8 +(default 4). Paideia optimization defaults to the provisioned descriptor count; an +explicit smaller `--concurrency` is allowed. The launcher does not inject this flag, +so authored comparisons retain serial execution. Every lease has separate worker identity, workspace, homes, +state, ledgers and a 100-port block. The integration must use its leased paths and +ports when preparing its Moltnet instance and native launch. + +`broker.maxRunRequests` defaults to 1000 and caps all upstream Grok attempts across +subjects, judges and optimizer grants, including credential retries. The broker +persists reservations before forwarding. Its counter lives on the named volume +`spawnfile-training-budget-` derived from the canonical host output mount +and mapped `--out` directory. Container cleanup retains this volume so `--resume` +cannot reset the cap; each new run uses a fresh output directory. Changing the cap +for an existing journal is refused. Remove the budget volume only when retiring +that run and its checkpoints. Slot state remains tmpfs and credentials stay on their +separate realm volume. The launcher sets `PAIDEIA_MODEL_REQUEST_CAP=broker`; +Paideia rejects non-broker inference routes (including fallbacks and legacy +defaults) and native graders in capped runs before constructing their providers. + ## Launch contract The advanced v1 path requires both `--training-image` (a digest reference or immutable @@ -280,7 +299,7 @@ one Grok credential in the container. ### Privilege table -The v3 slot always selects Daimon's manifest-v4 clocked worker config. The image +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; @@ -289,10 +308,10 @@ 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 `2100:2100 0755` -and its `faketime` file as `2100:2100 0644`, initially `+0\n`. The broker +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. Exactly one registration may use a clock-enabled container. +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, @@ -305,10 +324,10 @@ 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 v2, including +`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 v3; an old projection or protocol is refused. Each recycle +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 diff --git a/src/auth/AGENTS.md b/src/auth/AGENTS.md index 15c2046d..d617a126 100644 --- a/src/auth/AGENTS.md +++ b/src/auth/AGENTS.md @@ -8,6 +8,8 @@ This folder owns Spawnfile-managed auth profiles and auth import flows. src/auth/ ├── index.ts # Barrel exports ├── trainingAuth.ts # Public opaque training ingress staging to fixed native leaves +├── trainingGrokWriteback.ts # Journal-verified atomic return of renewed training Grok auth to the host import +├── trainingGrokWritebackLock.ts # Persistent non-secret SQLite transaction lock; kernel releases it on process death ├── types.ts # Auth profile types ├── paths.ts # Spawnfile auth home and profile path helpers ├── profileStore.ts # Read/write auth profiles and imported auth material diff --git a/src/auth/importers.grokConcurrency.test.ts b/src/auth/importers.grokConcurrency.test.ts new file mode 100644 index 00000000..f53fecbe --- /dev/null +++ b/src/auth/importers.grokConcurrency.test.ts @@ -0,0 +1,95 @@ +import { createHash } from "node:crypto"; +import * as fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, expect, it, vi } from "vitest"; +import { importGrokAuth } from "./importers.js"; +import { writeBackTrainingGrokCredential } from "./trainingGrokWriteback.js"; +import { acquireTrainingGrokWritebackLock } from "./trainingGrokWritebackLock.js"; + +// Interpose only at publication; all reads, writes and SQLite locking are real. +vi.mock("node:fs/promises", async (original) => ({ ...await original() })); +const roots: string[] = []; +afterEach(async () => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + for (const root of roots.splice(0)) await fs.rm(root, { recursive: true, force: true }); +}); +const credential = (day: number) => JSON.stringify({ "https://auth.x.ai::fixture": { + key: "synthetic", refresh_token: "synthetic", expires_at: `2030-01-0${day}T00:00:00Z` +} }); +const digest = (bytes: string) => createHash("sha256").update(bytes).digest("hex"); +const fixture = async () => { + const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "grok-import-race-"))); roots.push(root); + vi.stubEnv("SPAWNFILE_HOME", path.join(root, "home")); + const source = path.join(root, "source"); + await fs.mkdir(source); + await fs.writeFile(path.join(source, "auth.json"), credential(1), { mode: 0o600 }); + const imported = await importGrokAuth("training", source); + const profile = path.join(imported.imports.grok!.path, "auth.json"); + const candidate = path.join(root, "candidate"), journal = path.join(root, "journal"); + await fs.writeFile(candidate, credential(2), { mode: 0o600 }); + await fs.writeFile(journal, JSON.stringify({ version: "noopolis.daimon.broker-credential-journal.v1", + state: "promoted", generation: 1, sourceDigest: digest(credential(1)), promotedDigest: digest(credential(2)) }), { mode: 0o600 }); + await fs.writeFile(path.join(source, "auth.json"), credential(3)); + return { source, profile, run: () => writeBackTrainingGrokCredential(profile, candidate, journal) }; +}; + +it("excludes re-import between writeback validation and publication, then preserves the retried newer import", async () => { + const f = await fixture(); + const rename = fs.rename; + let intercepted = false; + vi.spyOn(fs, "rename").mockImplementationOnce(async (from, to) => { + expect(to).toBe(f.profile); + intercepted = true; + await expect(importGrokAuth("training", f.source)).rejects.toThrow(/lock unavailable; retry/u); + expect(await fs.readFile(f.profile, "utf8")).toBe(credential(1)); + await rename(from, to); + }); + expect(await f.run()).toBe(true); + expect(intercepted).toBe(true); + await importGrokAuth("training", f.source); + expect(await f.run()).toBe(false); + expect(await fs.readFile(f.profile, "utf8")).toBe(credential(3)); +}); + +it("keeps the same lock inode across re-import and excludes writeback and other imports until publication", async () => { + const f = await fixture(); + const lock = path.join(`${f.profile}.training-writeback.lock`, "lock.sqlite"); + const before = await fs.stat(lock, { bigint: true }); + const old = await fs.open(f.profile, "r"); + const rename = fs.rename; + let intercepted = false; + let writeback: Promise | undefined; + vi.spyOn(fs, "rename").mockImplementationOnce(async (from, to) => { + expect(to).toBe(f.profile); + intercepted = true; + // Do not await the queued writer while this import still owns its lock. + writeback = f.run(); + await expect(importGrokAuth("training", f.source)).rejects.toThrow(/lock unavailable; retry/u); + expect(await fs.readFile(f.profile, "utf8")).toBe(credential(1)); + await rename(from, to); + }); + try { + await importGrokAuth("training", f.source); + expect(intercepted).toBe(true); + expect(await writeback).toBe(false); + const after = await fs.stat(lock, { bigint: true }); + expect([after.dev, after.ino]).toEqual([before.dev, before.ino]); + expect(await old.readFile("utf8")).toBe(credential(1)); + expect(await fs.readFile(f.profile, "utf8")).toBe(credential(3)); + expect((await fs.stat(f.profile)).mode & 0o777).toBe(0o600); + expect(await f.run()).toBe(false); + } finally { await old.close(); } +}); + +it("releases the import lock on publication failure and preserves the previous credential", async () => { + const f = await fixture(); + vi.spyOn(fs, "rename").mockRejectedValueOnce(new Error("synthetic publication failure")); + await expect(importGrokAuth("training", f.source)).rejects.toThrow("synthetic publication failure"); + expect(await fs.readFile(f.profile, "utf8")).toBe(credential(1)); + const release = await acquireTrainingGrokWritebackLock(f.profile); + release(); + await importGrokAuth("training", f.source); + expect(await fs.readFile(f.profile, "utf8")).toBe(credential(3)); +}); diff --git a/src/auth/importers.ts b/src/auth/importers.ts index c03f6357..d409ff26 100644 --- a/src/auth/importers.ts +++ b/src/auth/importers.ts @@ -2,13 +2,17 @@ import path from "node:path"; import os from "node:os"; import { + ensurePrivateDirectory, fileExists, readUtf8File, + writeDurableFile, writePrivateUtf8File } from "../filesystem/index.js"; import { SpawnfileError } from "../shared/index.js"; -import { registerImportedAuth, setAuthProfileEnv } from "./profileStore.js"; +import { ensureAuthProfile, registerImportedAuth, setAuthProfileEnv } from "./profileStore.js"; +import { resolveImportedAuthDirectory } from "./paths.js"; +import { acquireTrainingGrokWritebackLock } from "./trainingGrokWritebackLock.js"; const ENV_LINE_PATTERN = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/; const CLAUDE_CODE_KEYCHAIN_SERVICE = "Claude Code-credentials"; @@ -142,9 +146,20 @@ export const importGrokAuth = async ( if (!(await fileExists(authFilePath))) { throw new SpawnfileError("validation_error", `Grok auth file does not exist: ${authFilePath}`); } - const { directory, profile } = await registerImportedAuth(profileName, "grok"); - await writePrivateUtf8File(path.join(directory, "auth.json"), await readUtf8File(authFilePath)); - return profile; + await ensureAuthProfile(profileName); + const directory = resolveImportedAuthDirectory(profileName, "grok"); + await ensurePrivateDirectory(path.dirname(directory)); + await ensurePrivateDirectory(directory); + const destination = path.join(directory, "auth.json"); + const release = await acquireTrainingGrokWritebackLock(destination).catch(() => { + throw new SpawnfileError("validation_error", "Grok credential lock unavailable; retry import after the active writer finishes"); + }); + try { + const content = await readUtf8File(authFilePath); + const { profile } = await registerImportedAuth(profileName, "grok"); + await writeDurableFile(destination, content); + return profile; + } finally { release(); } }; export const importClaudeCodeAuth = async ( diff --git a/src/auth/index.ts b/src/auth/index.ts index 826a4e93..74d374c2 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -12,3 +12,4 @@ export * from "./targetSecretSourceLifecycle.js"; export * from "./targetSecretSourceResolver.js"; export { stageTrainingAuth } from "./trainingAuth.js"; export type { TrainingAuthStageOptions, TrainingAuthProvider } from "./trainingAuth.js"; +export { writeBackTrainingGrokCredential } from "./trainingGrokWriteback.js"; diff --git a/src/auth/profileStore.ts b/src/auth/profileStore.ts index 9c6364e7..b97d79a8 100644 --- a/src/auth/profileStore.ts +++ b/src/auth/profileStore.ts @@ -153,7 +153,9 @@ export const registerImportedAuth = async ( const current = (await loadAuthProfile(profileName)) ?? (await ensureAuthProfile(profileName)); const importDirectory = resolveImportedAuthDirectory(profileName, kind); await ensurePrivateDirectory(path.dirname(importDirectory)); - await removeDirectory(importDirectory); + // Grok import and writeback share a persistent lock inside this directory. + // Removing it would let another writer lock a different inode concurrently. + if (kind !== "grok") await removeDirectory(importDirectory); await ensurePrivateDirectory(importDirectory); const nextProfile: AuthProfile = { diff --git a/src/auth/trainingGrokWriteback.test.ts b/src/auth/trainingGrokWriteback.test.ts new file mode 100644 index 00000000..510fd217 --- /dev/null +++ b/src/auth/trainingGrokWriteback.test.ts @@ -0,0 +1,117 @@ +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, open, readFile, readdir, realpath, rm, stat, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, expect, it } from "vitest"; +import { writeBackTrainingGrokCredential } from "./trainingGrokWriteback.js"; + +const roots: string[] = []; +afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }); }); +const credential = (day: number) => JSON.stringify({ "https://auth.x.ai::fixture": { key: "synthetic", refresh_token: "synthetic", expires_at: `2030-01-${String(day).padStart(2, "0")}T00:00:00Z` } }); +const digest = (bytes: string) => createHash("sha256").update(bytes).digest("hex"); +const fixture = async (profileDay = 1, candidateDay = 2, override: Record = {}) => { + const root = await realpath(await mkdtemp(path.join(os.tmpdir(), "grok-writeback-"))); roots.push(root); + const profile = path.join(root, "profile"), candidate = path.join(root, "candidate"), journal = path.join(root, "journal"); + await writeFile(profile, credential(profileDay), { mode: 0o600 }); + await writeFile(candidate, credential(candidateDay), { mode: 0o600 }); + await writeFile(journal, JSON.stringify({ version: "noopolis.daimon.broker-credential-journal.v1", state: "promoted", generation: 2, + sourceDigest: digest(credential(1)), promotedDigest: digest(credential(candidateDay)), ...override }), { mode: 0o600 }); + return { root, profile, candidate, journal, run: () => writeBackTrainingGrokCredential(profile, candidate, journal) }; +}; +it("atomically replaces the import, leaves an open old inode intact, and starts the next run with renewed bytes", async () => { + const f = await fixture(), old = await open(f.profile, "r"); + try { + expect(await f.run()).toBe(true); + expect(await old.readFile("utf8")).toBe(credential(1)); + expect(await readFile(f.profile, "utf8")).toBe(credential(2)); + expect((await stat(f.profile)).mode & 0o777).toBe(0o600); + expect(await f.run()).toBe(false); + expect((await readdir(f.root)).sort()).toEqual(["candidate", "journal", "profile", "profile.training-writeback.lock"]); + } finally { await old.close(); } +}); +it.each([2, 3])("never overwrites an equal/newer profile (%i)", async (day) => { + const f = await fixture(day); + expect(await f.run()).toBe(false); + expect(await readFile(f.profile, "utf8")).toBe(credential(day)); +}); +it.each([{ state: "stale" }, { state: "refreshing" }, { generation: -1 }, { promotedDigest: "0".repeat(64) }])("rejects unproven rotations and preserves profile: %j", async (override) => { + const f = await fixture(1, 2, override); + await expect(f.run()).rejects.toThrow(/writeback failed/u); + expect(await readFile(f.profile, "utf8")).toBe(credential(1)); +}); +it("rejects malformed credential bytes without disclosing them", async () => { + const f = await fixture(); await writeFile(f.journal, "synthetic-private-invalid-json"); + await expect(f.run()).rejects.toThrow("Training Grok credential writeback failed"); +}); +it("rejects symlink imports without modifying their target", async () => { + const f = await fixture(); await rm(f.profile); await symlink(f.candidate, f.profile); + await expect(f.run()).rejects.toThrow(/writeback failed/u); + expect(await readFile(f.candidate, "utf8")).toBe(credential(2)); +}); +it("uses a proven next generation when rotation retains the same expiry", async () => { + const f = await fixture(1, 1); + const renewed = credential(1).replace('"key":"synthetic"', '"key":"synthetic-renewed"'); + await writeFile(f.candidate, renewed); + await writeFile(f.journal, JSON.stringify({ version: "noopolis.daimon.broker-credential-journal.v1", state: "promoted", generation: 2, + sourceDigest: digest(credential(1)), promotedDigest: digest(renewed) })); + expect(await f.run()).toBe(true); + expect(await readFile(f.profile, "utf8")).toBe(renewed); +}); + +it("writes back two same-expiry rotations from the original profile", async () => { + const f = await fixture(1, 1); + const intermediate = credential(1).replaceAll("synthetic", "synthetic-intermediate"); + const renewed = credential(1).replaceAll("synthetic", "synthetic-renewed"); + await writeFile(f.candidate, renewed); + await writeFile(f.journal, JSON.stringify({ version: "noopolis.daimon.broker-credential-journal.v1", state: "promoted", generation: 2, + sourceDigest: digest(intermediate), promotedDigest: digest(renewed), ancestorDigests: [digest(credential(1)), digest(intermediate)] })); + expect(await f.run()).toBe(true); + expect(await readFile(f.profile, "utf8")).toBe(renewed); +}); +it("does not use another realm's generation to replace unrelated same-expiry auth", async () => { + const f = await fixture(1, 1, { generation: 99, sourceDigest: "a".repeat(64), ancestorDigests: ["b".repeat(64)] }); + const renewed = credential(1).replaceAll("synthetic", "synthetic-unrelated"); + await writeFile(f.candidate, renewed); + const journal = JSON.parse(await readFile(f.journal, "utf8")); + journal.promotedDigest = digest(renewed); + await writeFile(f.journal, JSON.stringify(journal)); + expect(await f.run()).toBe(false); + expect(await readFile(f.profile, "utf8")).toBe(credential(1)); +}); +it("recovers a directory abandoned by the former lock implementation", async () => { + const f = await fixture(); + await mkdir(`${f.profile}.training-writeback.lock`, { mode: 0o700 }); + expect(await f.run()).toBe(true); + expect(await readFile(f.profile, "utf8")).toBe(credential(2)); +}); +it.each(["invalid", ["invalid"], Array(901).fill("a".repeat(64))])("refuses malformed ancestry", async (ancestorDigests) => { + const f = await fixture(1, 2, { ancestorDigests }); + await expect(f.run()).rejects.toThrow(/writeback failed/u); + expect(await readFile(f.profile, "utf8")).toBe(credential(1)); +}); + +it("excludes concurrent realm writebacks and retains the newest expiry regardless of completion order", async () => { + const f = await fixture(), newest = path.join(f.root, "newest"), journal = path.join(f.root, "newest-journal"); + await writeFile(newest, credential(3), { mode: 0o600 }); + await writeFile(journal, JSON.stringify({ version: "noopolis.daimon.broker-credential-journal.v1", state: "promoted", + generation: 1, sourceDigest: digest(credential(1)), promotedDigest: digest(credential(3)) }), { mode: 0o600 }); + const writeNewest = () => writeBackTrainingGrokCredential(f.profile, newest, journal); + const results = await Promise.allSettled([f.run(), writeNewest()]); + expect(results.every(result => result.status === "fulfilled")).toBe(true); + expect(await readFile(f.profile, "utf8")).toBe(credential(3)); +}); + +it("never rolls a same-expiry lineage back when an older slot/run snapshot finishes last", async () => { + const f = await fixture(1, 1), intermediate = credential(1).replaceAll("synthetic", "synthetic-intermediate"); + const newest = credential(1).replaceAll("synthetic", "synthetic-newest"); + await writeFile(f.candidate, newest); + await writeFile(f.journal, JSON.stringify({ version: "noopolis.daimon.broker-credential-journal.v1", state: "promoted", + generation: 2, sourceDigest: digest(intermediate), promotedDigest: digest(newest), + ancestorDigests: [digest(credential(1)), digest(intermediate)] })); + expect(await f.run()).toBe(true); + await writeFile(f.candidate, intermediate); + await writeFile(f.journal, JSON.stringify({ version: "noopolis.daimon.broker-credential-journal.v1", state: "promoted", + generation: 1, sourceDigest: digest(credential(1)), promotedDigest: digest(intermediate) })); + expect(await f.run()).toBe(false); + expect(await readFile(f.profile, "utf8")).toBe(newest); +}); diff --git a/src/auth/trainingGrokWriteback.ts b/src/auth/trainingGrokWriteback.ts new file mode 100644 index 00000000..46c46c44 --- /dev/null +++ b/src/auth/trainingGrokWriteback.ts @@ -0,0 +1,82 @@ +import { createHash, randomUUID } from "node:crypto"; +import { constants } from "node:fs"; +import { lstat, open, realpath, rename, unlink } from "node:fs/promises"; +import path from "node:path"; +import { acquireTrainingGrokWritebackLock } from "./trainingGrokWritebackLock.js"; + +const failure = (): Error => Error("Training Grok credential writeback failed"); +const digest = (bytes: Buffer): string => createHash("sha256").update(bytes).digest("hex"); + +/** Reads only private, bounded, canonical leaves. Errors never include credential bytes. */ +export const readTrainingGrokLeaf = async (file: string): Promise => { + if (await realpath(file) !== path.resolve(file)) throw failure(); + const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + const before = await handle.stat({ bigint: true }); + if (!before.isFile() || before.nlink !== 1n || (before.mode & 0o777n) !== 0o600n || before.size < 2n || before.size > 65536n) throw failure(); + const bytes = Buffer.alloc(Number(before.size) + 1); + let length = 0; + while (length < bytes.length) { + const result = await handle.read(bytes, length, bytes.length - length, null); + if (!result.bytesRead) break; + length += result.bytesRead; + } + const after = await handle.stat({ bigint: true }); + if (BigInt(length) !== before.size || after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs) { + bytes.fill(0); throw failure(); + } + return bytes.subarray(0, length); + } finally { await handle.close(); } +}; + +const expiry = (bytes: Buffer): number => { + try { + const rows = Object.entries(JSON.parse(bytes.toString("utf8")) as Record) + .filter(([key]) => /^https:\/\/auth\.x\.ai::/u.test(key)).map(([, value]) => value as Record); + const row = rows[0]; + if (rows.length !== 1 || typeof row?.key !== "string" || !row.key.trim() || typeof row.refresh_token !== "string" || !row.refresh_token.trim() || typeof row.expires_at !== "string") throw failure(); + const value = Date.parse(row.expires_at); + if (!Number.isFinite(value)) throw failure(); + return value; + } catch { throw failure(); } +}; + +/** A settled journal proves rotation; native expiry orders credentials across independent realms. */ +export const writeBackTrainingGrokCredential = async (profile: string, candidate: string, journalFile: string): Promise => { + const release = await acquireTrainingGrokWritebackLock(profile, 30_000).catch(() => { throw failure(); }); + const temporary = path.join(path.dirname(profile), `.grok-writeback-${randomUUID()}`); + let current: Buffer | undefined, renewed: Buffer | undefined, journalBytes: Buffer | undefined; + try { + current = await readTrainingGrokLeaf(profile); + renewed = await readTrainingGrokLeaf(candidate); + if (digest(current) === digest(renewed)) return false; + journalBytes = await readTrainingGrokLeaf(journalFile); + const journal = JSON.parse(journalBytes.toString("utf8")) as Record; + if (journal.version !== "noopolis.daimon.broker-credential-journal.v1" || journal.state !== "promoted" || + !Number.isSafeInteger(journal.generation) || (journal.generation as number) < 1 || + typeof journal.sourceDigest !== "string" || !/^[a-f0-9]{64}$/u.test(journal.sourceDigest) || journal.promotedDigest !== digest(renewed)) throw failure(); + const renewedExpiry = expiry(renewed), currentExpiry = expiry(current); + const ancestors = journal.ancestorDigests ?? []; + if (!Array.isArray(ancestors) || ancestors.length > 900 || ancestors.some((entry) => typeof entry !== "string" || !/^[a-f0-9]{64}$/u.test(entry))) throw failure(); + if (renewedExpiry < currentExpiry || (renewedExpiry === currentExpiry && journal.sourceDigest !== digest(current) && !ancestors.includes(digest(current)))) return false; + const identity = await lstat(profile, { bigint: true }); + const output = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600); + try { await output.writeFile(renewed); await output.sync(); } finally { await output.close(); } + // Import and writeback hold the same lock through publication. This check + // also detects non-cooperating edits made before validation, not after it. + const check = await readTrainingGrokLeaf(profile); + try { + const now = await lstat(profile, { bigint: true }); + if (digest(check) !== digest(current) || now.ino !== identity.ino || now.dev !== identity.dev || now.ctimeNs !== identity.ctimeNs) throw failure(); + } finally { check.fill(0); } + await rename(temporary, profile); + const directory = await open(path.dirname(profile), constants.O_RDONLY); + try { await directory.sync(); } finally { await directory.close(); } + return true; + } catch { throw failure(); } + finally { + current?.fill(0); renewed?.fill(0); journalBytes?.fill(0); + try { await unlink(temporary).catch((error: NodeJS.ErrnoException) => { if (error.code !== "ENOENT") throw error; }); } + finally { release(); } + } +}; diff --git a/src/auth/trainingGrokWritebackLock.test.ts b/src/auth/trainingGrokWritebackLock.test.ts new file mode 100644 index 00000000..bc7b3b59 --- /dev/null +++ b/src/auth/trainingGrokWritebackLock.test.ts @@ -0,0 +1,77 @@ +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { once } from "node:events"; +import { mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, expect, it } from "vitest"; +import { acquireTrainingGrokWritebackLock } from "./trainingGrokWritebackLock.js"; +import { writeBackTrainingGrokCredential } from "./trainingGrokWriteback.js"; + +const roots: string[] = []; +afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }); }); + +it("excludes a live holder and reacquires after release", async () => { + const root = await realpath(await mkdtemp(path.join(os.tmpdir(), "grok-lock-"))); roots.push(root); + const profile = path.join(root, "profile"); + const release = await acquireTrainingGrokWritebackLock(profile); + try { + await expect(acquireTrainingGrokWritebackLock(profile)).rejects.toThrow(/locked/u); + await expect(acquireTrainingGrokWritebackLock(profile, 50)).rejects.toThrow(/locked/u); + // A same-process contender must not accidentally release the kernel lock. + const module = new URL("./trainingGrokWritebackLock.ts", import.meta.url).href; + const contender = spawn(process.execPath, ["--import", "tsx", "--input-type=module", "-e", ` + import { acquireTrainingGrokWritebackLock } from ${JSON.stringify(module)}; + try { const release = await acquireTrainingGrokWritebackLock(process.argv[1]); release(); process.exitCode = 2; } + catch (error) { if (!/locked/u.test(error.message)) process.exitCode = 3; } + `, profile], { stdio: "ignore" }); + expect((await once(contender, "exit"))[0]).toBe(0); + } + finally { release(); } + const again = await acquireTrainingGrokWritebackLock(profile); + again(); +}); + +it("yields to a same-process holder and acquires without caller retries", async () => { + const root = await realpath(await mkdtemp(path.join(os.tmpdir(), "grok-lock-wait-"))); roots.push(root); + const profile = path.join(root, "profile"); + const release = await acquireTrainingGrokWritebackLock(profile); + const timer = setTimeout(release, 50); + try { + const again = await acquireTrainingGrokWritebackLock(profile, 1000); + again(); + } finally { clearTimeout(timer); } +}); + +it("reacquires after SIGKILL without finally or deleting the lock inode", async () => { + const root = await realpath(await mkdtemp(path.join(os.tmpdir(), "grok-lock-crash-"))); roots.push(root); + const profile = path.join(root, "profile"); + const module = new URL("./trainingGrokWritebackLock.ts", import.meta.url).href; + const child = spawn(process.execPath, ["--import", "tsx", "--input-type=module", "-e", ` + import { acquireTrainingGrokWritebackLock } from ${JSON.stringify(module)}; + await acquireTrainingGrokWritebackLock(process.argv[1]); + process.send("locked"); + setInterval(() => {}, 1000); + `, profile], { stdio: ["ignore", "ignore", "pipe", "ipc"] }); + const exited = once(child, "exit"); + try { + await Promise.race([once(child, "message").then(([message]) => expect(message).toBe("locked")), + exited.then(() => { throw Error("Lock holder exited before acquisition"); })]); + await expect(acquireTrainingGrokWritebackLock(profile)).rejects.toThrow(/locked/u); + const waiting = acquireTrainingGrokWritebackLock(profile, 1000); + const timer = setTimeout(() => { child.kill("SIGKILL"); }, 50); + try { (await waiting)(); } finally { clearTimeout(timer); } + expect((await exited)[1]).toBe("SIGKILL"); + const credential = (day: number) => JSON.stringify({ "https://auth.x.ai::fixture": { + key: "synthetic", refresh_token: "synthetic", expires_at: `2030-01-0${day}T00:00:00Z` + } }); + const digest = (bytes: string) => createHash("sha256").update(bytes).digest("hex"); + const candidate = path.join(root, "candidate"), journal = path.join(root, "journal"); + await writeFile(profile, credential(1), { mode: 0o600 }); + await writeFile(candidate, credential(2), { mode: 0o600 }); + await writeFile(journal, JSON.stringify({ version: "noopolis.daimon.broker-credential-journal.v1", state: "promoted", + generation: 1, sourceDigest: digest(credential(1)), promotedDigest: digest(credential(2)) }), { mode: 0o600 }); + expect(await writeBackTrainingGrokCredential(profile, candidate, journal)).toBe(true); + expect(await readFile(profile, "utf8")).toBe(credential(2)); + } finally { child.kill("SIGKILL"); await exited; } +}); diff --git a/src/auth/trainingGrokWritebackLock.ts b/src/auth/trainingGrokWritebackLock.ts new file mode 100644 index 00000000..c03fe923 --- /dev/null +++ b/src/auth/trainingGrokWritebackLock.ts @@ -0,0 +1,44 @@ +import { closeSync, constants, openSync } from "node:fs"; +import { lstat, mkdir, realpath } from "node:fs/promises"; +import path from "node:path"; +import { setTimeout } from "node:timers/promises"; + +/** A persistent, non-secret SQLite file owns the OS lock, never the directory. + * SQLite releases it on process death. Do not unlink the file/directory: another + * writer may already have the same inode open. Legacy empty lock dirs are safe. + */ +export const acquireTrainingGrokWritebackLock = async (profile: string, timeoutMs = 0): Promise<() => void> => { + if (!Number.isFinite(timeoutMs) || timeoutMs < 0) throw Error("Invalid writeback lock timeout"); + const root = `${profile}.training-writeback.lock`; + await mkdir(root, { recursive: true, mode: 0o700 }); + const directory = await lstat(root); + if (await realpath(root) !== path.resolve(root) || !directory.isDirectory() || + directory.uid !== process.getuid?.() || (directory.mode & 0o777) !== 0o700) throw Error("Unsafe writeback lock"); + const file = path.join(root, "lock.sqlite"); + // Closing an independently opened fd can drop this process's POSIX locks. + // Only open when creating; SQLite owns every fd for an existing lock file. + // Creation and close must not yield to another acquisition in this process. + try { + closeSync(openSync(file, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600)); + } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; } + const entry = await lstat(file); + if (!entry.isFile() || entry.nlink !== 1 || entry.uid !== process.getuid?.() || (entry.mode & 0o777) !== 0o600) throw Error("Unsafe writeback lock"); + const { DatabaseSync } = await import("node:sqlite"); + const database = new DatabaseSync(file); + const deadline = performance.now() + timeoutMs; + try { + // SQLite's synchronous busy timeout would block a same-process holder + // from finishing its async publication. Yield between acquisition attempts. + database.exec("PRAGMA busy_timeout = 0"); + for (;;) { + try { + database.exec("BEGIN EXCLUSIVE"); + return () => { database.close(); }; + } catch (error) { + const remaining = deadline - performance.now(); + if ((error as { errcode?: number }).errcode !== 5 || remaining <= 0) throw error; + await setTimeout(Math.min(25, remaining)); + } + } + } catch (error) { database.close(); throw error; } +}; diff --git a/src/cli/paideiaDelegation.test.ts b/src/cli/paideiaDelegation.test.ts index 891fadd5..1408d89b 100644 --- a/src/cli/paideiaDelegation.test.ts +++ b/src/cli/paideiaDelegation.test.ts @@ -22,7 +22,7 @@ const completed = 'console.log(JSON.stringify({status:"completed",index:"/isolat async function command(body: string): Promise { const directory = await mkdtemp(path.join(os.tmpdir(), "spawnfile-paideia-child-")); directories.push(directory); - const executable = path.join(directory, "paideia"); + const executable = path.join(directory, "paideia.cjs"); // Generated process fixture, not an alternate maintained implementation. await writeFile(executable, `#!${process.execPath}\n${body}\n`); await chmod(executable, 0o700); @@ -184,3 +184,12 @@ async function descendantTrial(inherited: boolean, cancel: boolean): Promise process.kill(pid, 0)).toThrow(/ESRCH/u); } + +it("routes v4 image declarations through preparation validation before launching anything", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "spawnfile-training-v4-")); + directories.push(directory); + const file = path.join(directory, "launch.json"); + await writeFile(file, JSON.stringify({ version: "spawnfile.training-container.v4", image: {}, slots: 0 })); + const run = await invoke('throw Error("must never launch");', { trainingConfig: file, dryRun: false }); + await expect(run.result).rejects.toThrow(/slots/); +}); diff --git a/src/cli/paideiaDelegation.ts b/src/cli/paideiaDelegation.ts index bd424f19..044307b7 100644 --- a/src/cli/paideiaDelegation.ts +++ b/src/cli/paideiaDelegation.ts @@ -137,8 +137,8 @@ export const delegatePaideiaTraining = async (options: DelegatePaideiaTrainingOp if (options.repairWitness && !options.repairMeasurements) throw failure("--repair-witness requires --repair-measurements"); if (options.trainingConfig) { const config = await readBoundedJson(options.trainingConfig) as { version?: unknown; image?: unknown }; - // v2 and v3 are both declarative preparations; a v3 *launch* config (already lowered, with `inputs`) goes straight to Docker. - if (config.version === "spawnfile.training-container.v2" || (config.version === "spawnfile.training-container.v3" && config.image !== undefined)) { + // Image declarations go through preparation; lowered launch configurations go straight to Docker. + if (config.version === "spawnfile.training-container.v2" || (["spawnfile.training-container.v3", "spawnfile.training-container.v4"].includes(String(config.version)) && config.image !== undefined)) { if (options.trainingImage) throw failure("V2 owns its image declaration; --training-image is only for v1"); const prepared = await prepareTraining({ configPath: options.trainingConfig, context: options.context, args: options.args, repairMeasurements: options.repairMeasurements, repairWitness: options.repairWitness, diff --git a/src/cli/targetCommands.crossProcess.test.ts b/src/cli/targetCommands.crossProcess.test.ts index b3269872..e49c68ad 100644 --- a/src/cli/targetCommands.crossProcess.test.ts +++ b/src/cli/targetCommands.crossProcess.test.ts @@ -116,7 +116,7 @@ const createState = async (): Promise => { mkdir(home, { mode: 0o700 }), mkdir(output, { mode: 0o700 }), mkdir(requests, { mode: 0o700 }), writeFile(callsPath, "", { mode: 0o600 }) ]); - const fakeDocker = path.join(root, "docker-safe"); + const fakeDocker = path.join(root, "docker-safe.cjs"); const resourcePath = path.join(root, "docker-resources.json"); await writeFile(fakeDocker, `#!${process.execPath} const fs = require("node:fs"); diff --git a/src/cli/targetWorldClockCrossProcess.test-helper.ts b/src/cli/targetWorldClockCrossProcess.test-helper.ts index 4433e837..83ce99f9 100644 --- a/src/cli/targetWorldClockCrossProcess.test-helper.ts +++ b/src/cli/targetWorldClockCrossProcess.test-helper.ts @@ -96,7 +96,7 @@ export const createBuiltWorldClockState = async (): Promise { }); describe("spawnfile train", () => { + it.each([[], ["--concurrency", "2"], ["--concurrency=2"]])("forwards explicit concurrency to the launcher without imposing a default: %j", async (...flags) => { + const delegate = vi.fn(async (_options: DelegatePaideiaTrainingOptions) => 0); + const stderr: string[] = []; + expect(await runCli(["train", await project(), ...base, ...container, ...flags], { + handlers: { delegatePaideiaTraining: delegate }, + streams: { stdout() {}, stderr: (value) => stderr.push(value) } + })).toBe(0); + expect(stderr).toEqual([]); + expect(delegate).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ + args: [...base, ...(flags.length ? ["--concurrency", "2"] : [])], + trainingConfig: "/training.json", dryRun: false + })); + }); + it("resolves the real canonical project and forwards only explicit Paideia options", async () => { const root = await project(), stdout: string[] = [], stderr: string[] = []; const delegate = vi.fn(async (_options: DelegatePaideiaTrainingOptions) => 0); diff --git a/src/cli/trainCommand.ts b/src/cli/trainCommand.ts index df9cc8b8..014ca41c 100644 --- a/src/cli/trainCommand.ts +++ b/src/cli/trainCommand.ts @@ -4,7 +4,7 @@ import { SpawnfileError } from "../shared/index.js"; import type { CliHandlers, CliStreams } from "./runCli.js"; const forwarded = ["train", "test", "editable", "resource", "judge", "judge-citation-repairs", "validation-group", "optimizer-model", - "bridge-command", "out", "max-trials", "max-proposals", "seed", "timeout-ms", "view", "cost-config"] as const; + "bridge-command", "out", "max-trials", "max-proposals", "seed", "timeout-ms", "view", "cost-config", "concurrency"] as const; const repeated = new Set(["editable", "resource", "judge", "judge-citation-repairs", "validation-group"]); const key = (name: string): string => name.replace(/-([a-z])/gu, (_, letter: string) => letter.toUpperCase()); diff --git a/src/compiler/AGENTS.md b/src/compiler/AGENTS.md index 95653df4..0353c900 100644 --- a/src/compiler/AGENTS.md +++ b/src/compiler/AGENTS.md @@ -44,7 +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 # Clocked config selection/count gate and shared root clock provisioning +├── 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/buildProject.test.ts b/src/compiler/buildProject.test.ts index 70ce95d6..bf5b2690 100644 --- a/src/compiler/buildProject.test.ts +++ b/src/compiler/buildProject.test.ts @@ -66,7 +66,7 @@ printf '%s\\n' ${safeArchitecture} const createFakeMoltnetCli = async (): Promise => { const directory = await mkdtemp(path.join(os.tmpdir(), "spawnfile-fake-moltnet-cli-")); temporaryDirectories.push(directory); - const commandPath = path.join(directory, "moltnet"); + const commandPath = path.join(directory, "moltnet.cjs"); await writeUtf8File( commandPath, [ diff --git a/src/compiler/compileProject.test.ts b/src/compiler/compileProject.test.ts index 59246dec..9494e902 100644 --- a/src/compiler/compileProject.test.ts +++ b/src/compiler/compileProject.test.ts @@ -41,7 +41,7 @@ const createFakeMoltnetCli = async (): Promise => { const directory = await mkdtemp(path.join(os.tmpdir(), "spawnfile-moltnet-cli-")); temporaryDirectories.push(directory); - const cliPath = path.join(directory, "moltnet"); + const cliPath = path.join(directory, "moltnet.cjs"); await writeUtf8File( cliPath, [ diff --git a/src/compiler/compileProjectOrganizationReadyEvidence.test.ts b/src/compiler/compileProjectOrganizationReadyEvidence.test.ts index 19d3363e..42e8b9b8 100644 --- a/src/compiler/compileProjectOrganizationReadyEvidence.test.ts +++ b/src/compiler/compileProjectOrganizationReadyEvidence.test.ts @@ -29,7 +29,7 @@ afterEach(async () => { const createFakeMoltnetCli = async (): Promise => { const root = await mkdtemp(path.join(os.tmpdir(), "spawnfile-evidence-moltnet-")); directories.push(root); - const cliPath = path.join(root, "moltnet"); + const cliPath = path.join(root, "moltnet.cjs"); await writeFile(cliPath, [ "#!/usr/bin/env node", "const fs = require('node:fs');", diff --git a/src/compiler/containerDaimonBrokerCredential.test.ts b/src/compiler/containerDaimonBrokerCredential.test.ts new file mode 100644 index 00000000..f4104651 --- /dev/null +++ b/src/compiler/containerDaimonBrokerCredential.test.ts @@ -0,0 +1,72 @@ +import { createHash } from "node:crypto"; +import * as fs from "node:fs"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { runInNewContext } from "node:vm"; +import { afterEach, expect, it } from "vitest"; +import { DAIMON_BROKER_REALM, renderDaimonBrokerProvisioningProgram } from "./containerDaimonBrokerRender.js"; + +const roots: string[] = []; +afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }); }); +const credential = (day: number) => JSON.stringify({ "https://auth.x.ai::fixture": { key: "synthetic", refresh_token: "synthetic", expires_at: `2030-01-${String(day).padStart(2, "0")}T00:00:00Z` } }); +const digest = (bytes: string) => createHash("sha256").update(bytes).digest("hex"); +const fixture = async (state: string, bootstrapDay: number, realmDay = 2) => { + const root = await mkdtemp(path.join(os.tmpdir(), "grok-lineage-")); roots.push(root); + const realm = path.join(root, "realm"), bootstrap = path.join(root, "bootstrap"); + await mkdir(path.join(realm, ".daimon-broker"), { recursive: true, mode: 0o700 }); + await writeFile(bootstrap, credential(bootstrapDay), { mode: 0o600 }); + const auth = path.join(realm, "auth.json"), journal = path.join(realm, ".daimon-broker/credential-journal.json"); + await writeFile(auth, credential(realmDay), { mode: 0o600 }); + await writeFile(journal, JSON.stringify({ version: "noopolis.daimon.broker-credential-journal.v1", state, generation: 7, + sourceDigest: digest(credential(realmDay)), promotedDigest: digest(credential(realmDay)) }), { mode: 0o600 }); + const all = renderDaimonBrokerProvisioningProgram([], []).join("\n"); + const script = all.slice(all.indexOf("const bootstrap ="), all.indexOf("\n", all.indexOf("try { const journalPath ="))) + .replaceAll(DAIMON_BROKER_REALM, realm).replaceAll("/var/lib/spawnfile/daimon/grok-bootstrap-auth", bootstrap); + const owners = new Map(); + const run = () => runInNewContext(script, { Buffer, process, crypto: createRequire(import.meta.url)("node:crypto"), require: createRequire(import.meta.url), + fs: { ...fs, chownSync: (file: string, uid: number, gid: number) => { owners.set(file, { uid, gid }); }, fchownSync: () => {}, lstatSync: (file: string) => Object.assign(fs.lstatSync(file), owners.get(file) ?? { uid: 2100, gid: 2100 }) } }); + return { auth, journal, bootstrap, run }; +}; + +it("replays the production startup/recycle program without replacing a newer promoted realm", async () => { + const f = await fixture("promoted", 1); + f.run(); f.run(); + expect(await readFile(f.auth, "utf8")).toBe(credential(2)); + expect(JSON.parse(await readFile(f.journal, "utf8")).generation).toBe(7); +}); +it.each([1, 2])("refuses stale recovery from older/equal bootstrap expiry (%i) without changing auth or generation", async (day) => { + const f = await fixture("stale", day); + if (day === 2) await writeFile(f.bootstrap, credential(day).replace('"key":"synthetic"', '"key":"synthetic-other"')); + const before = await readFile(f.journal, "utf8"); + expect(f.run).toThrow(/older broker bootstrap|unsafe broker credential recovery/u); + expect(await readFile(f.auth, "utf8")).toBe(credential(2)); + expect(await readFile(f.journal, "utf8")).toBe(before); +}); +it("recovers only a strictly newer bootstrap and advances the durable generation once", async () => { + const f = await fixture("stale", 3); + f.run(); f.run(); + expect(await readFile(f.auth, "utf8")).toBe(credential(3)); + expect(JSON.parse(await readFile(f.journal, "utf8"))).toMatchObject({ state: "promoted", generation: 8, promotedDigest: digest(credential(3)) }); +}); +it("does not seed a missing authority from an old bootstrap when a journal survives", async () => { + const f = await fixture("promoted", 1); + await rm(f.auth); + expect(f.run).toThrow(/refusing bootstrap rollback/u); + await expect(readFile(f.auth)).rejects.toMatchObject({ code: "ENOENT" }); +}); +it("seeds a genuinely empty realm once", async () => { + const f = await fixture("promoted", 1); + await rm(f.auth); await rm(f.journal); + f.run(); f.run(); + expect(await readFile(f.auth, "utf8")).toBe(credential(1)); +}); + +it("restores broker ownership after rejecting rollback so an operator can later supply fresh auth", async () => { + const f = await fixture("stale", 1); + expect(f.run).toThrow(/older broker bootstrap/u); + await writeFile(f.bootstrap, credential(3)); + f.run(); + expect(await readFile(f.auth, "utf8")).toBe(credential(3)); +}); diff --git a/src/compiler/containerDaimonBrokerRender.ts b/src/compiler/containerDaimonBrokerRender.ts index ca297518..61d73487 100644 --- a/src/compiler/containerDaimonBrokerRender.ts +++ b/src/compiler/containerDaimonBrokerRender.ts @@ -178,7 +178,9 @@ export const renderDaimonBrokerProvisioningProgram = ( * pass a path outside it: a `tmp/` inside a cleared root is a mount point in * the training launch, and clearing a mount point fails `EBUSY`. */ - brokerTmpdir: string = DAIMON_BROKER_TMPDIR + brokerTmpdir: string = DAIMON_BROKER_TMPDIR, + /** Root-owned training deny targets, created after root reset and before worker attestation. */ + beforeWorkerProvisioning: readonly string[] = [] ): string[] => { const program = [ "const crypto = require('node:crypto'); const fs = require('node:fs');", @@ -202,8 +204,8 @@ export const renderDaimonBrokerProvisioningProgram = ( `const atomicOwned = (target, bytes) => { const temporary = \`${"${target}"}.\${process.pid}.\${crypto.randomUUID()}.tmp\`; try { const output = fs.openSync(temporary, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600); try { let written = 0; while (written < bytes.length) written += fs.writeSync(output, bytes, written, bytes.length - written, written); fs.fchmodSync(output, 0o600); fs.fchownSync(output, 2100, 2100); fs.fsyncSync(output); } finally { fs.closeSync(output); } fs.renameSync(temporary, target); const directory = fs.openSync(require('node:path').dirname(target), fs.constants.O_RDONLY | fs.constants.O_DIRECTORY); try { fs.fsyncSync(directory); } finally { fs.closeSync(directory); } } catch (error) { try { fs.unlinkSync(temporary); } catch {} throw error; } };`, "let existing; try { existing = fs.lstatSync(authority); } catch (error) { if (error.code !== 'ENOENT') throw error; } const bootstrapBytes = readSecure(bootstrap, undefined, 'bootstrap'); let bootstrapRecord; try { const root = JSON.parse(bootstrapBytes.toString('utf8')), rows = root && typeof root === 'object' && !Array.isArray(root) ? Object.entries(root).filter(([key, value]) => /^https:\\/\\/auth\\.x\\.ai::/.test(key) && value && typeof value === 'object' && !Array.isArray(value)).map(([, value]) => value) : []; if (rows.length !== 1 || typeof rows[0].key !== 'string' || !rows[0].key.trim() || typeof rows[0].refresh_token !== 'string' || !rows[0].refresh_token.trim() || typeof rows[0].expires_at !== 'string' || !Number.isFinite(Date.parse(rows[0].expires_at))) throw new Error(); bootstrapRecord = true; } catch { bootstrapBytes.fill(0); throw new Error('invalid broker credential bootstrap'); } if (!bootstrapRecord) throw new Error('invalid broker credential bootstrap'); const bootstrapDigest = crypto.createHash('sha256').update(bootstrapBytes).digest('hex');", `const journalRoot = '${DAIMON_BROKER_REALM}/.daimon-broker'; let journalRootExists = false; try { const info = fs.lstatSync(journalRoot); if (!info.isDirectory() || info.isSymbolicLink() || info.uid !== 2100 || info.gid !== 2100 || (info.mode & 0o777) !== 0o700) throw new Error('unsafe broker credential journal directory'); fs.chownSync(journalRoot, 0, 0); fs.chmodSync(journalRoot, 0o700); journalRootExists = true; } catch (error) { if (error.code !== 'ENOENT') throw error; }`, - `try { const journalPath = '${DAIMON_BROKER_REALM}/.daimon-broker/credential-journal.json'; let journal; try { const raw = readSecure(journalPath, 2100, 'recovery journal'); journal = JSON.parse(raw.toString('utf8')); raw.fill(0); } catch (error) { if (error.code !== 'ENOENT') throw error; } const stale = journal?.version === 'noopolis.daimon.broker-credential-journal.v1' && journal.state === 'stale'; const recover = () => { if (!stale || !Number.isSafeInteger(journal.generation) || journal.generation < 0 || !/^[a-f0-9]{64}$/.test(journal.sourceDigest) || journal.sourceDigest !== journal.promotedDigest || bootstrapDigest === journal.sourceDigest) throw new Error('unsafe broker credential recovery'); atomicOwned(authority, bootstrapBytes); const recovered = Buffer.from(\`${"${JSON.stringify({ version: 'noopolis.daimon.broker-credential-journal.v1', state: 'promoted', generation: journal.generation + 1, sourceDigest: journal.sourceDigest, promotedDigest: bootstrapDigest })}"}\\n\`); try { atomicOwned(journalPath, recovered); } finally { recovered.fill(0); } }; if (!existing) { if (stale) recover(); else atomicOwned(authority, bootstrapBytes); } else { const authorityBytes = readSecure(authority, 2100, 'authority'); try { const authorityDigest = crypto.createHash('sha256').update(authorityBytes).digest('hex'); if (stale) { if (authorityDigest !== journal.sourceDigest && authorityDigest !== bootstrapDigest) throw new Error('unsafe broker credential recovery'); if (authorityDigest === bootstrapDigest) { const recovered = Buffer.from(\`${"${JSON.stringify({ version: 'noopolis.daimon.broker-credential-journal.v1', state: 'promoted', generation: journal.generation + 1, sourceDigest: journal.sourceDigest, promotedDigest: bootstrapDigest })}"}\\n\`); try { atomicOwned(journalPath, recovered); } finally { recovered.fill(0); } } else recover(); } } finally { authorityBytes.fill(0); } } } finally { bootstrapBytes.fill(0); }`, - "if (journalRootExists) { fs.chownSync(journalRoot, 0, 0); fs.chmodSync(journalRoot, 0o700); fs.chownSync(journalRoot, 2100, 2100); }", + "const credentialExpiry = (bytes) => { try { const rows = Object.entries(JSON.parse(bytes.toString('utf8'))).filter(([key]) => /^https:\\/\\/auth\\.x\\.ai::/.test(key)).map(([, value]) => value); const expiry = rows.length === 1 && typeof rows[0]?.expires_at === 'string' ? Date.parse(rows[0].expires_at) : NaN; if (!Number.isFinite(expiry)) throw new Error(); return expiry; } catch { throw new Error('invalid broker credential expiry'); } };", + `try { const journalPath = '${DAIMON_BROKER_REALM}/.daimon-broker/credential-journal.json'; let journal; try { const raw = readSecure(journalPath, 2100, 'recovery journal'); journal = JSON.parse(raw.toString('utf8')); raw.fill(0); } catch (error) { if (error.code !== 'ENOENT') throw error; } const stale = journal?.version === 'noopolis.daimon.broker-credential-journal.v1' && journal.state === 'stale'; const recover = () => { if (!stale || !Number.isSafeInteger(journal.generation) || journal.generation < 0 || !/^[a-f0-9]{64}$/.test(journal.sourceDigest) || journal.sourceDigest !== journal.promotedDigest || bootstrapDigest === journal.sourceDigest) throw new Error('unsafe broker credential recovery'); atomicOwned(authority, bootstrapBytes); const recovered = Buffer.from(\`${"${JSON.stringify({ version: 'noopolis.daimon.broker-credential-journal.v1', state: 'promoted', generation: journal.generation + 1, sourceDigest: journal.sourceDigest, promotedDigest: bootstrapDigest })}"}\\n\`); try { atomicOwned(journalPath, recovered); } finally { recovered.fill(0); } }; if (!existing) { if (journal) throw new Error('broker credential authority missing; refusing bootstrap rollback'); else atomicOwned(authority, bootstrapBytes); } else { const authorityBytes = readSecure(authority, 2100, 'authority'); try { const authorityDigest = crypto.createHash('sha256').update(authorityBytes).digest('hex'); if (stale) { if (authorityDigest !== journal.sourceDigest && authorityDigest !== bootstrapDigest) throw new Error('unsafe broker credential recovery'); if (authorityDigest === bootstrapDigest && bootstrapDigest === journal.sourceDigest) throw new Error('unsafe broker credential recovery'); if (authorityDigest !== bootstrapDigest && credentialExpiry(bootstrapBytes) <= credentialExpiry(authorityBytes)) throw new Error('older broker bootstrap cannot recover newer realm'); if (authorityDigest === bootstrapDigest) { const recovered = Buffer.from(\`${"${JSON.stringify({ version: 'noopolis.daimon.broker-credential-journal.v1', state: 'promoted', generation: journal.generation + 1, sourceDigest: journal.sourceDigest, promotedDigest: bootstrapDigest })}"}\\n\`); try { atomicOwned(journalPath, recovered); } finally { recovered.fill(0); } } else recover(); } } finally { authorityBytes.fill(0); } } } finally { bootstrapBytes.fill(0); if (journalRootExists) { fs.chownSync(journalRoot, 0, 0); fs.chmodSync(journalRoot, 0o700); fs.chownSync(journalRoot, 2100, 2100); } fs.chownSync('${DAIMON_BROKER_REALM}', 0, 0); fs.chmodSync('${DAIMON_BROKER_REALM}', 0o700); fs.chownSync('${DAIMON_BROKER_REALM}', 2100, 2100); }`, ...renderDaimonWorkspaceResourceSecurity(workspaceResources), ...renderDaimonGrokWorkerProvisioning(registrations, serviceOptions, ...(optionalDenyPaths ? [optionalDenyPaths] : [])), `fs.chownSync('${DAIMON_BROKER_REALM}', 0, 0); fs.chmodSync('${DAIMON_BROKER_REALM}', 0o700); fs.chownSync('${DAIMON_BROKER_REALM}', 2100, 2100);`, @@ -217,6 +219,7 @@ export const renderDaimonBrokerProvisioningProgram = ( : [...MOUNT_AWARE_CLEAR_HELPER, "for broker_root in /etc/daimon-engine-broker /run/daimon-engine-broker; do spawnfile_clear_tree \"$broker_root\"; done"]), `install -d -o root -g ${DAIMON_BROKER_UID} -m 0731 /run/daimon-engine-broker`, `install -d -o ${DAIMON_BROKER_UID} -g ${DAIMON_BROKER_UID} -m 0700 ${brokerTmpdir}`, + ...beforeWorkerProvisioning, "node <<'SPAWNFILE_DAIMON_BROKER_PROVISION'", program, "SPAWNFILE_DAIMON_BROKER_PROVISION" diff --git a/src/compiler/containerDaimonGrokTaskClock.ts b/src/compiler/containerDaimonGrokTaskClock.ts index 29c7864c..a0229225 100644 --- a/src/compiler/containerDaimonGrokTaskClock.ts +++ b/src/compiler/containerDaimonGrokTaskClock.ts @@ -1,4 +1,3 @@ -import { SpawnfileError } from "../shared/index.js"; import { DAIMON_GROK_ENGINE_BROKER } from "../runtime/daimon/contractManifest.js"; import type { DaimonGrokRegistration } from "./containerDaimonGrokWorkerRender.js"; @@ -13,26 +12,18 @@ export const GROK_SOCKET_PLACEHOLDER_PROGRAM = [ "server.listen(file, () => { fs.chmodSync(file, 0o600); fs.chownSync(file, Number(uid), Number(gid)); process.exit(0); });" ].join("\n"); -/** A fixed clock file cannot serve concurrent registrations, even mixed variants. */ -export const grokProvisioningConfigPins = (registrations: readonly DaimonGrokRegistration[]) => { - if (!registrations.some((entry) => entry.workerConfigVariant === "clocked")) return broker.worker.configSha256; - if (registrations.length !== clock.maxRegistrations) { - throw new SpawnfileError("compile_error", "Clocked Grok slots require exactly one registration"); - } - return broker.worker.clockedConfigSha256; -}; - /** Lines inside the shared root program, after its canonical-path and ownership helpers. */ export const renderDaimonGrokTaskClockProvisioning = (registrations: readonly DaimonGrokRegistration[]): string[] => { - grokProvisioningConfigPins(registrations); - if (!registrations.some((entry) => entry.workerConfigVariant === "clocked")) return []; + 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.directoryPath);; 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.directoryPath, 0, 0, taskClock.directoryMode); assertCanonical(taskClock.directoryPath, 'task clock');", - "ensureExactFile(taskClock.timestampFilePath, '+0\\n', taskClock.fileMode); fs.chownSync(taskClock.timestampFilePath, taskClock.ownerUid, taskClock.ownerUid);", - "withMode(taskClock.directoryPath, taskClock.directoryMode, taskClock.ownerUid, taskClock.ownerUid);", + "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. diff --git a/src/compiler/containerDaimonGrokWorkerProvisioning.pins.test.ts b/src/compiler/containerDaimonGrokWorkerProvisioning.pins.test.ts new file mode 100644 index 00000000..2095ce15 --- /dev/null +++ b/src/compiler/containerDaimonGrokWorkerProvisioning.pins.test.ts @@ -0,0 +1,70 @@ +import crypto from "node:crypto"; +import vm from "node:vm"; + +import { describe, expect, it } from "vitest"; + +import { DAIMON_GROK_BROKER_MODELS, DAIMON_GROK_BROKER_REASONING_EFFORTS } from "../runtime/daimon/contractManifest.js"; +import { resolveDaimonGrokWorkerConfig } from "../runtime/daimon/grokWorkerContract.js"; +import { renderDaimonGrokWorkerProvisioning } from "./containerDaimonGrokWorkerProvisioning.js"; +import type { DaimonGrokRegistration } from "./containerDaimonGrokWorkerRender.js"; +import { renderTrainingBrokerProvisioning, renderTrainingSlotProvisioning } from "./training/broker/provisioning.js"; +import { resolveTrainingGrokRegistration } from "./training/broker/registration.js"; + +const registration = (slot: number) => resolveTrainingGrokRegistration({ + agentId: "agent", model: "grok-4.6", reasoningEffort: "low", slot, slots: 4 +}); + +// Run the actual emitted guard, stopping before filesystem provisioning begins. +const executeGuard = (lines: string[]): void => { + lines = lines.flatMap(line => line.split("\n")); + const start = lines.findIndex(line => line.startsWith("const grokWorkers =")); + const end = lines.findIndex(line => line.includes("contract bytes for")); + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + vm.runInNewContext(lines.slice(start, end + 1).join("\n"), { crypto }); +}; +const guard = (entries: DaimonGrokRegistration[]) => executeGuard(renderDaimonGrokWorkerProvisioning(entries)); +const reject = (entry: DaimonGrokRegistration) => expect(() => guard([entry])).toThrow(/do not match their pins/u); + +describe("generated Grok configuration pin guard", () => { + it("accepts all four clocked slots at startup and during individual recycle", () => { + const entries = [0, 1, 2, 3].map(registration); + executeGuard(renderTrainingBrokerProvisioning(entries)); + for (const entry of entries) executeGuard(renderTrainingSlotProvisioning(entry)); + }); + + it("accepts each declared model and effort with a nonzero clock slot", () => { + for (const model of DAIMON_GROK_BROKER_MODELS) for (const reasoningEffort of DAIMON_GROK_BROKER_REASONING_EFFORTS) { + guard([resolveTrainingGrokRegistration({ agentId: "agent", model, reasoningEffort, slot: 7, slots: 8 })]); + } + }); + + it("rejects modified bytes even with a matching caller-supplied digest", () => { + const entry = registration(2); + const config = `${entry.config}\n# tampered\n`; + reject({ ...entry, config }); + reject({ ...entry, config, configSha256: crypto.createHash("sha256").update(config).digest("hex") }); + reject({ ...entry, configSha256: "0".repeat(64) }); + }); + + it("rejects another slot's valid bytes and another model or effort", () => { + const entry = registration(2); + const other = registration(1); + reject({ ...entry, config: other.config, configSha256: other.configSha256 }); + reject({ ...entry, model: "grok-4.5" }); + reject({ ...entry, reasoningEffort: "high" }); + }); + + it("rejects unclocked bytes for a clocked registration", () => { + const entry = registration(0); + const unclocked = resolveDaimonGrokWorkerConfig(entry.model, entry.reasoningEffort); + reject({ ...entry, config: unclocked.bytes, configSha256: unclocked.sha256 }); + }); + + it("keeps unclocked registrations compatible and rejects clocked bytes under that variant", () => { + const entry = { ...registration(0), configVariant: undefined }; + reject(entry); + const unclocked = resolveDaimonGrokWorkerConfig(entry.model, entry.reasoningEffort); + guard([{ ...entry, config: unclocked.bytes, configSha256: unclocked.sha256 }]); + }); +}); diff --git a/src/compiler/containerDaimonGrokWorkerProvisioning.test.ts b/src/compiler/containerDaimonGrokWorkerProvisioning.test.ts index 65996483..a5aefddc 100644 --- a/src/compiler/containerDaimonGrokWorkerProvisioning.test.ts +++ b/src/compiler/containerDaimonGrokWorkerProvisioning.test.ts @@ -9,6 +9,7 @@ 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"; @@ -138,6 +139,28 @@ describe("Grok worker home provisioning", () => { 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); diff --git a/src/compiler/containerDaimonGrokWorkerProvisioning.ts b/src/compiler/containerDaimonGrokWorkerProvisioning.ts index fee68a39..1ced760b 100644 --- a/src/compiler/containerDaimonGrokWorkerProvisioning.ts +++ b/src/compiler/containerDaimonGrokWorkerProvisioning.ts @@ -1,6 +1,7 @@ +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"; -import { grokProvisioningConfigPins, renderDaimonGrokTaskClockProvisioning } from "./containerDaimonGrokTaskClock.js"; import { DAIMON_GROK_DENIED_STATE_ROOTS, DAIMON_GROK_OPTIONAL_DENY_PATHS, @@ -64,13 +65,18 @@ export const renderDaimonGrokWorkerProvisioning = ( * are tmpfs mounts and `/run/secrets` and the shared state roots do not * exist there at all. */ - optionalDenyPaths: readonly string[] = [...DAIMON_GROK_OPTIONAL_DENY_PATHS, ...DAIMON_GROK_DENIED_STATE_ROOTS] + optionalDenyPaths: readonly string[] = [...DAIMON_GROK_OPTIONAL_DENY_PATHS, ...DAIMON_GROK_DENIED_STATE_ROOTS], + scope: "container" | "slot" = "container" ): string[] => [ `const grokWorkers = ${JSON.stringify(registrations.map(programRegistration))};`, `const optionalDenyPaths = new Set(${JSON.stringify([...optionalDenyPaths])});`, - `const pinnedConfigSha256 = ${JSON.stringify(grokProvisioningConfigPins(registrations))};`, + // Derive each expected hash from manifest-validated bytes, never from the registration's digest. + // Clocked templates are pinned at slot zero, then specialized by the contract resolver. + `const pinnedConfigSha256 = ${JSON.stringify(registrations.map(entry => (entry.configVariant === "clocked" + ? resolveDaimonGrokClockedWorkerConfig(entry.model, entry.reasoningEffort, entry.slot) + : resolveDaimonGrokWorkerConfig(entry.model, entry.reasoningEffort)).sha256))};`, "const sha256Hex = (value) => crypto.createHash('sha256').update(value).digest('hex');", - "for (const entry of grokWorkers) { if (sha256Hex(entry.config) !== entry.configSha256 || !Object.hasOwn(pinnedConfigSha256, entry.model) || !Object.hasOwn(pinnedConfigSha256[entry.model], entry.reasoningEffort) || pinnedConfigSha256[entry.model][entry.reasoningEffort] !== entry.configSha256 || sha256Hex(entry.profile) !== entry.profileSha256 || entry.denyPaths.length === 0) throw new Error(`Grok worker contract bytes for ${entry.agentId} do not match their pins`); }", + "for (const [index, entry] of grokWorkers.entries()) { if (sha256Hex(entry.config) !== entry.configSha256 || pinnedConfigSha256[index] !== entry.configSha256 || sha256Hex(entry.profile) !== entry.profileSha256 || entry.denyPaths.length === 0) throw new Error(`Grok worker contract bytes for ${entry.agentId} do not match their pins`); }", // Root holds no CAP_FOWNER: every mode change reclaims the inode, sets the mode, then restores or hands over ownership. "const withMode = (target, mode, uid, gid) => { const info = fs.lstatSync(target); if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`unsafe Grok worker directory: ${target}`); fs.chownSync(target, 0, 0); fs.chmodSync(target, mode); fs.chownSync(target, uid, gid); };", // Root holds no CAP_DAC_OVERRIDE either: before it may create inside a directory the ownership pass @@ -81,18 +87,18 @@ 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}); };`, - `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');`, - ...renderDaimonGrokTaskClockProvisioning(registrations), + ...(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'); 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'); }`, + `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'); }`, "for (const denied of optionalDenyPaths) { try { fs.lstatSync(denied); } catch (error) { if (error.code !== 'ENOENT') throw error; fs.mkdirSync(denied, { mode: 0o700 }); fs.chownSync(denied, 0, 0); fs.chmodSync(denied, 0o700); } }", "for (const entry of grokWorkers) for (const denied of entry.denyPaths) { try { assertCanonical(denied, 'deny path'); } catch (error) { if (error.code === 'ENOENT' && entry.deferredDenyPaths.includes(denied)) continue; if (error.code === 'ENOENT') throw new Error(`Grok worker deny path is missing: ${denied}`); throw error; } }", // Pass 2: exact root-owned read-only files, the events file, then the final sticky modes. - `for (const entry of grokWorkers) { ensureExactFile(\`\${entry.grokHome}/config.toml\`, entry.config, 0o444); ensureExactFile(entry.profilePath, entry.profile, 0o444); for (const name of ${JSON.stringify(DAIMON_GROK_WORKER_READ_ONLY_FILES.filter((name) => name !== "config.toml" && name !== "sandbox.toml"))}) ensureExactFile(\`\${entry.grokHome}/\${name}\`, '', 0o444); ensureEventsFile(entry.eventsPath, entry.uid); ensureDirectory(\`\${entry.grokHome}/sessions\`, 0, entry.uid, 0o1771); ensureDirectory(entry.grokHome, 0, entry.uid, 0o1771); ensureDirectory(entry.privateTmp, entry.uid, entry.uid, 0o700); ensureDirectory(entry.home, entry.uid, ${DAIMON_BROKER_UID}, 0o710); for (const target of [entry.profilePath, entry.eventsPath, \`\${entry.grokHome}/config.toml\`]) assertCanonical(target, 'home file'); }`, + `for (const entry of grokWorkers) { ensureExactFile(\`\${entry.grokHome}/config.toml\`, entry.config, 0o444); ensureExactFile(entry.profilePath, entry.profile, 0o444); for (const name of ${JSON.stringify(DAIMON_GROK_WORKER_READ_ONLY_FILES.filter((name) => name !== "config.toml" && name !== "sandbox.toml"))}) ensureExactFile(\`\${entry.grokHome}/\${name}\`, '', 0o444); ensureEventsFile(entry.eventsPath, entry.uid); ensureDirectory(\`\${entry.grokHome}/sessions\`, 0, entry.uid, 0o1771); ensureDirectory(entry.grokHome, 0, entry.uid, 0o1771); ${scope === "container" ? `ensureDirectory(entry.privateTmp, entry.uid, entry.uid, 0o700); ensureDirectory(entry.home, entry.uid, ${DAIMON_BROKER_UID}, 0o710);` : ""} for (const target of [entry.profilePath, entry.eventsPath, \`\${entry.grokHome}/config.toml\`]) assertCanonical(target, 'home file'); }`, // Worker-private temp: the launcher compiles TMPDIR=/tmp, the only temp the worker may write. It is // created in the pass above, while the home is still root-owned: root here holds no CAP_DAC_OVERRIDE, so // once the home is `: 0710` root can no longer create anything inside it. - "for (const entry of grokWorkers) { ensureDirectory(entry.privateTmp, entry.uid, entry.uid, 0o700); assertCanonical(entry.privateTmp, 'private temp'); }", + `for (const entry of grokWorkers) { ${scope === "container" ? "ensureDirectory(entry.privateTmp, entry.uid, entry.uid, 0o700);" : ""} assertCanonical(entry.privateTmp, 'private temp'); }`, // Spills: /tool-output 2000: 2750 (setgid) under a runtime home the worker's group can // traverse. Root reclaims the runtime home before creating the spill directory: the compiler lists every // organization runtime home as a private directory, so the container's ownership pass has already handed @@ -101,7 +107,7 @@ export const renderDaimonGrokWorkerProvisioning = ( // `withMode` still narrows the home to its final `2000: 0710`, so the end state is unchanged. `for (const entry of grokWorkers) { traversable(entry.runtimeHome); fs.mkdirSync(entry.runtimeHome, { recursive: true, mode: 0o700 }); assertCanonical(entry.runtimeHome, 'runtime home'); reclaimForRoot(entry.runtimeHome); try { fs.mkdirSync(entry.spillDirectory, { mode: 0o700 }); } catch (error) { if (error.code !== 'EEXIST') throw error; } assertCanonical(entry.spillDirectory, 'spill directory'); withMode(entry.spillDirectory, 0o2750, ${DAIMON_ORGANIZATION_UID}, entry.uid); for (const mounted of entry.runtimeHomeMounts) { assertCanonical(mounted, 'runtime home mount'); withMode(mounted, 0o700, ${DAIMON_ORGANIZATION_UID}, ${DAIMON_ORGANIZATION_UID}); } withMode(entry.runtimeHome, 0o710, ${DAIMON_ORGANIZATION_UID}, entry.uid); }`, // Shared temp: Grok refuses a profile denying /tmp or /var/tmp, so modes close them: root: 1774 lets workers list names only. - `for (const shared of ${JSON.stringify(DAIMON_GROK_ENGINE_BROKER.worker.home.sharedTmp.paths)}) { fs.mkdirSync(shared, { recursive: true, mode: 0o1777 }); assertCanonical(shared, 'shared temp'); withMode(shared, 0o${DAIMON_GROK_ENGINE_BROKER.worker.home.sharedTmp.mode.toString(8)}, 0, ${DAIMON_ORGANIZATION_UID}); }`, + ...(scope === "slot" ? [] : [`for (const shared of ${JSON.stringify(DAIMON_GROK_ENGINE_BROKER.worker.home.sharedTmp.paths)}) { fs.mkdirSync(shared, { recursive: true, mode: 0o1777 }); assertCanonical(shared, 'shared temp'); withMode(shared, 0o${DAIMON_GROK_ENGINE_BROKER.worker.home.sharedTmp.mode.toString(8)}, 0, ${DAIMON_ORGANIZATION_UID}); }`]), // Deny-path placement, asserted once every mode above is final. A resource backing the organization // entrypoint only materializes later is created here, empty and root-owned, rather than skipped: the // engine broker starts before that entrypoint runs and its own placement preflight refuses a deny target @@ -119,9 +125,9 @@ export const renderDaimonGrokWorkerProvisioning = ( "const searches = (info, uid, gid) => info.uid === uid ? (info.mode & 0o100) !== 0 : info.gid === gid ? (info.mode & 0o010) !== 0 : (info.mode & 0o001) !== 0;", "const assertPlaceable = (denied, uid, gid) => { const parts = denied.split('/').slice(1); let at = ''; for (const part of parts.slice(0, -1)) { at += `/${part}`; let info; try { info = fs.lstatSync(at); } catch (error) { throw new Error(`Grok worker deny path ${denied} is not placeable: ${at} could not be read (${error.code})`); } if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`Grok worker deny path ${denied} is not placeable: ${at} is not a directory`); if (!searches(info, uid, gid)) throw new Error(`Grok worker deny path ${denied} is not placeable: worker uid ${uid} cannot search ${at} (${(info.mode & 0o7777).toString(8)} ${info.uid}:${info.gid}); deny that directory itself instead`); } };", "for (const entry of grokWorkers) for (const denied of entry.denyPaths) { if (entry.deferredDenyPaths.includes(denied)) { try { fs.lstatSync(denied); } catch (error) { if (error.code !== 'ENOENT') throw error; createDeferredDenyTarget(denied); } } assertPlaceable(denied, entry.uid, entry.uid); }", - `const service = ${JSON.stringify(renderDaimonGrokServiceConfig(registrations, serviceOptions))};`, - "for (const registration of service.registrations) { const entry = grokWorkers.find((worker) => worker.agentId === registration.agentId); if (!entry || registration.profileSha256 !== sha256Hex(fs.readFileSync(entry.profilePath, 'utf8'))) throw new Error('Grok broker service registration does not match its provisioned profile'); }", - `fs.writeFileSync('${DAIMON_GROK_ENGINE_BROKER.serviceConfigPath}', \`\${JSON.stringify(service)}\\n\`, { mode: 0o440, flag: 'wx' }); fs.chownSync('${DAIMON_GROK_ENGINE_BROKER.serviceConfigPath}', 0, ${DAIMON_BROKER_UID}); fs.chmodSync('${DAIMON_GROK_ENGINE_BROKER.serviceConfigPath}', 0o440);` + ...(scope === "slot" ? [] : [`const service = ${JSON.stringify(renderDaimonGrokServiceConfig(registrations, serviceOptions))};`, + "for (const registration of service.registrations) { const entry = grokWorkers.find((worker) => worker.slot === registration.slot); if (!entry || registration.profileSha256 !== sha256Hex(fs.readFileSync(entry.profilePath, 'utf8'))) throw new Error('Grok broker service registration does not match its provisioned profile'); }", + `fs.writeFileSync('${DAIMON_GROK_ENGINE_BROKER.serviceConfigPath}', \`\${JSON.stringify(service)}\\n\`, { mode: 0o440, flag: 'wx' }); fs.chownSync('${DAIMON_GROK_ENGINE_BROKER.serviceConfigPath}', 0, ${DAIMON_BROKER_UID}); fs.chmodSync('${DAIMON_GROK_ENGINE_BROKER.serviceConfigPath}', 0o440);`]) ]; const shellQuote = (value: string): string => `'${value.replace(/'/g, `'"'"'`)}'`; diff --git a/src/compiler/containerDaimonGrokWorkerRender.ts b/src/compiler/containerDaimonGrokWorkerRender.ts index 45cbd92d..44d2a0d7 100644 --- a/src/compiler/containerDaimonGrokWorkerRender.ts +++ b/src/compiler/containerDaimonGrokWorkerRender.ts @@ -23,8 +23,7 @@ import { DAIMON_GROK_WORKER_HOME_DIRECTORY, daimonGrokWorkerSandboxProfileSha256, renderDaimonGrokWorkerSandboxProfile, - resolveDaimonGrokWorkerConfig, - type DaimonGrokWorkerConfigVariant + resolveDaimonGrokWorkerConfig } from "../runtime/daimon/grokWorkerContract.js"; import { DAIMON_FIRST_WORKER_UID } from "../runtime/daimon/runtimeIdentity.js"; import type { RuntimeTargetPlan } from "./containerArtifactsTypes.js"; @@ -94,8 +93,8 @@ export interface DaimonGrokRegistration { agentId: string; config: string; configSha256: string; - /** Omission preserves production's unclocked config and multi-worker layout. */ - workerConfigVariant?: DaimonGrokWorkerConfigVariant; + /** Selects trusted config bytes; production retains the unclocked default. */ + configVariant?: "unclocked" | "clocked"; denyPaths: string[]; /** Deny entries the main entrypoint materializes after root provisioning (workspace resource backings); absent is allowed there, a symlink never. */ deferredDenyPaths: string[]; @@ -314,6 +313,10 @@ export const resolveDaimonGrokRegistrations = (plans: RuntimeTargetPlan[]): Daim }; export interface DaimonGrokServiceConfigOptions { + /** Repeated agent IDs use v3 slot policies; production retains v2 by default. */ + version?: "v3"; + maxRunRequests?: number; + runRequestStore?: string; /** * Where the broker keeps its durable turn registry. Production keeps it on * the realm volume beside the credential; training overrides it to per-slot @@ -348,9 +351,11 @@ export const renderDaimonGrokServiceConfig = ( registrations: readonly DaimonGrokRegistration[], options: DaimonGrokServiceConfigOptions = {} ) => ({ - version: DAIMON_GROK_ENGINE_BROKER.serviceConfigVersions[1], + version: DAIMON_GROK_ENGINE_BROKER.serviceConfigVersions[options.version === "v3" ? 2 : 1], credentialHome: DAIMON_GROK_ENGINE_BROKER.credentialHomePath, turnStore: resolveDaimonGrokTurnStore(options.turnStore), + ...(options.maxRunRequests === undefined ? {} : { maxRunRequests: options.maxRunRequests }), + ...(options.runRequestStore === undefined ? {} : { runRequestStore: assertCanonicalRegisteredPath("run request store", options.runRequestStore) }), ...(options.inferenceLedgerPath === undefined ? {} : { inferenceLedgerPath: resolveDaimonGrokInferenceLedger(options.inferenceLedgerPath, registrations) }), registrations: registrations.map((entry) => ({ agentId: entry.agentId, @@ -360,8 +365,13 @@ export const renderDaimonGrokServiceConfig = ( profilePath: entry.profilePath, eventsPath: entry.eventsPath, profileSha256: entry.profileSha256, - usageLedgerPath: entry.usageLedgerPath, + ...(options.version === "v3" ? {} : { usageLedgerPath: entry.usageLedgerPath, + limits: { ...(entry.limits ?? DAIMON_GROK_ENGINE_BROKER.turnLimits.v1Defaults) }, + model: { id: entry.model, reasoningEffort: entry.reasoningEffort } }) + })), + ...(options.version === "v3" ? { slotPolicies: registrations.map(entry => ({ + slot: entry.slot, usageLedgerPath: entry.usageLedgerPath, limits: { ...(entry.limits ?? DAIMON_GROK_ENGINE_BROKER.turnLimits.v1Defaults) }, model: { id: entry.model, reasoningEffort: entry.reasoningEffort } - })) + })) } : {}) }); diff --git a/src/compiler/mixedRuntimeOrg.test.ts b/src/compiler/mixedRuntimeOrg.test.ts index d1847695..919cd00d 100644 --- a/src/compiler/mixedRuntimeOrg.test.ts +++ b/src/compiler/mixedRuntimeOrg.test.ts @@ -34,7 +34,7 @@ const fixturesRoot = path.resolve(process.cwd(), "examples"); const createFakeMoltnetCli = async (): Promise => { const directory = await mkdtemp(path.join(os.tmpdir(), "spawnfile-mixed-moltnet-cli-")); temporaryDirectories.push(directory); - const cliPath = path.join(directory, "moltnet"); + const cliPath = path.join(directory, "moltnet.cjs"); await writeUtf8File( cliPath, [ diff --git a/src/compiler/publicDaimonHost.test.ts b/src/compiler/publicDaimonHost.test.ts index d2ec7593..a640adcf 100644 --- a/src/compiler/publicDaimonHost.test.ts +++ b/src/compiler/publicDaimonHost.test.ts @@ -19,11 +19,14 @@ afterEach(async () => { }); describe("public Daimon host fixture", () => { - it("requires a re-pin before production compiles can use the new manifest", async () => { + it("compiles a public host against the CI-published Daimon index with no local identity", async () => { const outputDirectory = await mkdtemp(path.join(os.tmpdir(), "spawnfile-public-daimon-")); temporaryDirectories.push(outputDirectory); - await expect(compileProject(fixture, { outputDirectory })).rejects.toThrow(/does not attest the compiler's exact contract manifest/u); - await expect(readUtf8File(path.join(outputDirectory, "Dockerfile"))).rejects.toThrow(); + await compileProject(fixture, { outputDirectory }); + const dockerfile = await readUtf8File(path.join(outputDirectory, "Dockerfile")); + expect(dockerfile).toContain("COPY --from=noopolis/spawnfile-runtime-daimon@sha256:d4389f1cb99a8d2bf13134737ae058c0e48019d465bd35fbb250d7cde138b836 "); + expect(dockerfile).toContain('amd64) expected="sha256:9db4f4d5b62c505a445429da9ae8cf8bce21590bd1b67f449d17e427934a5421"'); + expect(dockerfile).toContain('arm64) expected="sha256:8536fc5543378a8617a99bbdbddd0b6e909a6c0d4ed5f9bb5805d501a17ca90c"'); }); it("compiles against an explicit local identity without changing production registry pins", async () => { @@ -57,7 +60,7 @@ describe("public Daimon host fixture", () => { `COPY --from=${LOCAL_DAIMON_IMAGE_REPOSITORY}@${digest("c")} ` ); expect(dockerfile).toContain(digest("a")); - expect(dockerfile).not.toContain("sha256:85875f67af630e8b4e667a72f20f0b491940cfb3feb7fe132a13a84678147747"); + expect(dockerfile).not.toContain("sha256:d4389f1cb99a8d2bf13134737ae058c0e48019d465bd35fbb250d7cde138b836"); await expect((await import("node:fs/promises")).readFile(path.join(outputDirectory, "spawnfile-report.json"), "utf8").then(JSON.parse)).resolves.toMatchObject({ container: { local_daimon_runtime: { registry_authority: "127.0.0.1:54321", image_reference: `${LOCAL_DAIMON_IMAGE_REPOSITORY}@${digest("c")}` } } }); diff --git a/src/compiler/recordingCapability.test.ts b/src/compiler/recordingCapability.test.ts index 92b65ba6..d0436512 100644 --- a/src/compiler/recordingCapability.test.ts +++ b/src/compiler/recordingCapability.test.ts @@ -2,11 +2,11 @@ import { readFile, readdir } from "node:fs/promises"; import path from "node:path"; import { afterEach, expect, it, vi } from "vitest"; -import { capableDaimonManifest, useMatchingDaimonTestImage } from "../runtime/daimon/recording.test-helper.js"; +import { capableDaimonManifest } from "../runtime/daimon/recording.test-helper.js"; import { compileProject } from "./compileProject.js"; import { cleanTrainingProjects, trainingProject, writeTrainingFile } from "./training/plan/fixtures.test-helper.js"; -afterEach(async () => { vi.useRealTimers(); vi.unstubAllEnvs(); await cleanTrainingProjects(); }); +afterEach(async () => { vi.useRealTimers(); await cleanTrainingProjects(); }); const agent = (runtime: string, recording = true) => ({ spawnfile_version: "0.1", kind: "agent", name: "writer", runtime, ...(recording ? { training: { record: { keep: "2h", volumes: ["state"] } } } : {}), @@ -19,24 +19,23 @@ it.each(["openclaw", "picoclaw", "pi"])("rejects recording for %s with the exact message: expect.stringMatching(new RegExp(`agent:writer.*runtime ${runtime}`, "u")) }); }); -it("rejects recording against the actual pinned image and requires a release and re-pin", async () => { +it("compiles recording against the actual pinned image", async () => { const root = await trainingProject({ Spawnfile: agent("daimon") }); - await expect(compileProject(root)).rejects.toMatchObject({ code: "runtime_error", message: expect.stringContaining( - "Selected Daimon runtime image does not attest the compiler's exact contract manifest" - ) }); + const result = await compileProject(root); + expect(result.report.recording?.recording_mount_ids).toEqual(["daimon-recording-writer"]); }); -it("still refuses a manifest without recording authority", async () => { +it("rejects recording against an explicitly incapable manifest and requires a release and re-pin", async () => { const root = await trainingProject({ Spawnfile: agent("daimon") }); - const { wakeMoments, ...manifest } = capableDaimonManifest(); - const incapable = { ...manifest, - consumedConfigFields: manifest.consumedConfigFields.filter((field) => field !== wakeMoments!.configField) }; - await expect(compileProject(root, { daimonContractManifest: incapable })).rejects.toThrow(/does not declare the wakeMoments capability/u); + const manifest = { ...capableDaimonManifest() }; + delete manifest.wakeMoments; + await expect(compileProject(root, { daimonContractManifest: manifest })).rejects.toMatchObject({ code: "validation_error", message: expect.stringContaining( + "pinned Daimon runtime image does not declare the wakeMoments capability; a Daimon release with it and a runtimes.yaml / contract-manifest re-pin are required" + ) }); }); it("compiles an injected capable contract, emits snapshots at their mounted backing path, and protects the store", async () => { const root = await trainingProject({ Spawnfile: agent("daimon") }); - useMatchingDaimonTestImage(root); const result = await compileProject(root, { daimonContractManifest: capableDaimonManifest() }); const config = JSON.parse(await readFile(path.join(result.outputDirectory, "container/rootfs/var/lib/spawnfile/instances/daimon/daimon-organization/daimon/daimon-organization-runtime.json"), "utf8")); @@ -62,7 +61,6 @@ const emittedFiles = async (directory: string, relative = ""): Promise { const manifest = agent("daimon", false); const root = await trainingProject({ Spawnfile: manifest }); - useMatchingDaimonTestImage(root); vi.useFakeTimers({ toFake: ["Date"] }); vi.setSystemTime(new Date("2026-10-09T08:20:31Z")); const before = await compileProject(root); const bytes = await emittedFiles(before.outputDirectory); diff --git a/src/compiler/recordingReport.test.ts b/src/compiler/recordingReport.test.ts index 0c3512cd..cbbf0df8 100644 --- a/src/compiler/recordingReport.test.ts +++ b/src/compiler/recordingReport.test.ts @@ -1,16 +1,16 @@ import path from "node:path"; import { readFile } from "node:fs/promises"; -import { afterEach, expect, it, vi } from "vitest"; +import { afterEach, expect, it } from "vitest"; import { parseDistributionReport } from "../distribution/index.js"; -import { capableDaimonManifest, useMatchingDaimonTestImage } from "../runtime/daimon/recording.test-helper.js"; +import { capableDaimonManifest } from "../runtime/daimon/recording.test-helper.js"; import { buildCompilePlan } from "./buildCompilePlan.js"; import { compileProject } from "./compileProject.js"; import { createMemoryArtifactBundle } from "./memoryArtifacts.js"; import { resolveRecordingReport } from "./recordingReport.js"; import { cleanTrainingProjects, trainingProject, writeTrainingFile } from "./training/plan/fixtures.test-helper.js"; -afterEach(async () => { vi.unstubAllEnvs(); await cleanTrainingProjects(); }); +afterEach(cleanTrainingProjects); const memory = (id: string) => ({ id, store: { kind: "json", path: `/data/${id}/events.jsonl` } }); const agent = (name: string, training?: unknown) => ({ spawnfile_version: "0.1", kind: "agent", name, runtime: "daimon", memory: [memory(name)], ...(training ? { training } : {}) }); @@ -32,7 +32,6 @@ it("uses only compiled recording agents, protecting their own and accessible sha it("emits the opt-in field in real compile and distribution reports without loading training targets", async () => { const root = await trainingProject({ Spawnfile: agent("a") }); - useMatchingDaimonTestImage(root); const off = await compileProject(root); expect(off.report).not.toHaveProperty("recording"); // Use the compiler's emitted report path, which the Dockerfile copies into the image. diff --git a/src/compiler/training/broker/AGENTS.md b/src/compiler/training/broker/AGENTS.md index 6843459c..37716b45 100644 --- a/src/compiler/training/broker/AGENTS.md +++ b/src/compiler/training/broker/AGENTS.md @@ -1,30 +1,33 @@ # Broker-capable training container Owns the root privilege model, slot provisioning, slot recycle supervisor and -slot preflight receipt of `spawnfile.training-container.v3` — the training +slot preflight receipt of `spawnfile.training-container.v4` — the training container that runs a brokered Grok subject beside the evaluator. -- `paths.ts` fixes every container path. They are constants because the root +- `paths.ts` fixes every container path. Its slot descriptors are deterministic because the root entrypoint, the slot supervisor and Paideia's `launch.v2` must agree on them without talking to each other, and Daimon's projection never resolves a path. -- `declaration.ts` is the host's read-only `spawnfile.training-broker.v1` input. +- `declaration.ts` is the host's read-only `spawnfile.training-broker.v2` input. It is data, never code: no host executable or script is ever mounted, so the image's own Spawnfile distribution renders the provisioning from it. -- `registration.ts` builds the one `DaimonGrokRegistration` and its deny list. +- `registration.ts` builds clocked slot registrations and denies sibling roots, homes and clocks. +- `clockProbe.ts` proves sibling clock masks with bubblewrap under the worker uid; its own clock must remain readable. - `provisioning.ts` renders the root bash program, delegating everything below the slot skeleton to the production renderers in `../../containerDaimonBrokerRender.ts`. - Never fork that program: a recycle must replay the audited start-up path, - credential-journal recovery included. + Container startup provisions the shared service once. Recycle reuses only the + worker renderer; it never rewrites shared configuration or credential state. - `processes.ts` starts the launcher (root), broker and relay (2100) and proves each one's socket and post-drop uid/`CapBnd` before the next starts. -- `runtime.ts` implements drain, wipe, provision, start, canaries and receipt. -- `supervisor.ts` is the one-verb root socket server (one verb, one argument, +- `runtime.ts` implements slot-local wipe, provision, canaries and receipt. +- `reset.ts` sends the broker-owned slot reset request and verifies its acknowledgement. +- `supervisor.ts` is the one-verb root socket server (one verb, slot and nonce, no caller-supplied path or command) and owns `startTrainingSlot`, the only - way a slot comes up: `provision → start → canaries → generation → receipt`. + 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 on recycle, +- `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 v2 + 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 @@ -33,11 +36,11 @@ container that runs a brokered Grok subject beside the evaluator. Local constraints: -- Training always installs the clocked config; exactly one registration may use - the container's fixed clock directory. The shared root renderer creates - `/run/daimon-engine-broker/task-clock` as `2100:2100 0755` and `faketime` as - `2100:2100 0644`, initially `+0\n`. Reclaim that subtree before clearing the - control root on recycle; root lacks `CAP_DAC_OVERRIDE` and `CAP_FOWNER`. +- 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 @@ -90,7 +93,7 @@ Local constraints: worker attempt is a stronger denial than DAC and is recorded as such, with the layer named — derived from the pinned profile in `seccompRoutes.ts`, because errno cannot tell a seccomp `EPERM` from a kernel one. Per-route verdicts land - in `/run/training/slot/sealed-inputs.json`; the cross-repo + in `/run/training/slots//sealed-inputs.json`; the cross-repo `grok-slot-preflight.v2` canary shape is deliberately untouched. - 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 @@ -115,3 +118,13 @@ Local constraints: ownership from the deepest path up, and reclaim an inode (and its parent) before chmod-ing or unlinking it. - Keep files under 400 lines and tests adjacent. + +- Worker-home and private-temp inodes retain their owner/mode throughout recycle: every + broker turn attests all workers. Clear their contents, never remove those roots. +- Turn stores use the broker-derived `/run/training/turns/slot-` layout on shared + tmpfs; only the broker reset seam deletes a slot store or resets its clock. +- `slots.json` is generated by the launcher and bound read-only under `/run/paideia`; + it carries exact per-slot evaluator paths and 100-port blocks starting at 20000. +- The request-budget store is a separate broker-owned persistent volume, keyed by + the host output mount and mapped run output directory. Never wipe it on slot recycle + or remove it with a container: resume must keep every reserved provider attempt. diff --git a/src/compiler/training/broker/clockProbe.test.ts b/src/compiler/training/broker/clockProbe.test.ts new file mode 100644 index 00000000..ee3de11c --- /dev/null +++ b/src/compiler/training/broker/clockProbe.test.ts @@ -0,0 +1,22 @@ +import { expect, it, vi } from "vitest"; +import { probeTrainingSiblingClock } from "./clockProbe.js"; +import { trainingSlotPaths } from "./paths.js"; + +it("requires a positive sandbox denial and preserves access to the own clock", async () => { + const run = vi.fn().mockResolvedValue({ stdout: "denied\n" }); + expect(await probeTrainingSiblingClock(2, trainingSlotPaths(1).clockDirectory, run)).toBe(true); + const [command, args] = run.mock.calls[0]!; + expect(command).toBe("setpriv"); + expect(args).toContain("--reuid=2202"); + const script = args.at(-1); + expect(script).toContain('bwrap --die-with-parent --cap-drop ALL --ro-bind / / --ro-bind "$blocked"'); + expect(script).toContain(trainingSlotPaths(1).clockDirectory); + expect(script.match(new RegExp(trainingSlotPaths(2).clockFile, "g"))).toHaveLength(2); + expect(script).toContain('chmod 000 "$blocked"'); + run.mockResolvedValue({ stdout: "reachable\n" }); + expect(await probeTrainingSiblingClock(2, trainingSlotPaths(1).clockDirectory, run)).toBe(false); + run.mockResolvedValue({ stdout: "" }); + await expect(probeTrainingSiblingClock(2, trainingSlotPaths(1).clockDirectory, run)).rejects.toThrow(/no verified verdict/); + run.mockRejectedValue(new Error("namespace unavailable")); + await expect(probeTrainingSiblingClock(2, trainingSlotPaths(1).clockDirectory, run)).rejects.toThrow(/namespace unavailable/); +}); diff --git a/src/compiler/training/broker/clockProbe.ts b/src/compiler/training/broker/clockProbe.ts new file mode 100644 index 00000000..f7122b9d --- /dev/null +++ b/src/compiler/training/broker/clockProbe.ts @@ -0,0 +1,32 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { SpawnfileError } from "../../../shared/index.js"; +import { trainingSlotPaths } from "./paths.js"; + +const run = promisify(execFile); +const quote = (value: string): string => `'${value.replace(/'/gu, `'"'"'`)}'`; + +/** Clock directories are public by contract; prove the sibling mask in the worker's mount namespace. */ +export const probeTrainingSiblingClock = async ( + slot: number, target: string, + execute: (command: string, args: string[], options: { timeout: number }) => Promise<{ stdout: string }> = run +): Promise => { + const p = trainingSlotPaths(slot); + const script = [ + 'set -eu', + `cat ${quote(p.clockFile)} >/dev/null`, + // Use the same mode-000 directory bind as Grok's deny mounts. Neither an unavailable + // namespace nor a hidden own clock is a successful denial. + `blocked=$(mktemp -d ${quote(`${p.privateTmp}/clock-probe.XXXXXX`)})`, + 'trap \'chmod 700 "$blocked"; rmdir "$blocked"\' EXIT', + 'chmod 000 "$blocked"', + `bwrap --die-with-parent --cap-drop ALL --ro-bind / / --ro-bind "$blocked" ${quote(target)} -- /bin/sh -ceu ${quote( + `cat ${quote(p.clockFile)} >/dev/null; if ls -A ${quote(target)} >/dev/null 2>&1; then echo reachable; else echo denied; fi` + )}` + ].join("\n"); + const result = await execute("setpriv", ["--clear-groups", `--reuid=${p.workerUid}`, `--regid=${p.workerUid}`, + "--inh-caps=-all", "--ambient-caps=-all", "--bounding-set=-all", "--", "/bin/sh", "-ceu", script], { timeout: 10_000 }); + if (result.stdout.trim() === "denied") return true; + if (result.stdout.trim() === "reachable") return false; + throw new SpawnfileError("runtime_error", "Training clock canary returned no verified verdict"); +}; diff --git a/src/compiler/training/broker/declaration.ts b/src/compiler/training/broker/declaration.ts index 833c2bc2..26d6c6a0 100644 --- a/src/compiler/training/broker/declaration.ts +++ b/src/compiler/training/broker/declaration.ts @@ -12,7 +12,7 @@ const canonical = z.string().min(2).max(4_096).refine((value) => "canonical absolute path"); /** - * `spawnfile.training-broker.v1`: the only thing the host tells the + * `spawnfile.training-broker.v2`: the only thing the host tells the * broker-capable training container about its slot. It is data, never code — * the image's own Spawnfile distribution renders the provisioning from it with * the same renderers production uses, so no host-written executable is ever @@ -23,7 +23,9 @@ const canonical = z.string().min(2).max(4_096).refine((value) => * the named realm volume. */ export const trainingBrokerDeclarationSchema = z.strictObject({ - version: z.literal("spawnfile.training-broker.v1"), + version: z.literal("spawnfile.training-broker.v2"), + maxRunRequests: z.number().int().positive().max(Number.MAX_SAFE_INTEGER).default(1000), + slots: z.number().int().min(1).max(8).default(4), engine: z.literal("grok"), agentId: z.string().min(1).max(128).regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/u), model: z.enum(DAIMON_GROK_BROKER_MODELS), diff --git a/src/compiler/training/broker/entrypoint.test.ts b/src/compiler/training/broker/entrypoint.test.ts index a07a3482..efdf88b6 100644 --- a/src/compiler/training/broker/entrypoint.test.ts +++ b/src/compiler/training/broker/entrypoint.test.ts @@ -7,14 +7,15 @@ import { trainingBrokerMounts, trainingBrokerTmpfsTargets } from "../container/s import { assertNotDesktopGrokAuth, trainingChildArgv, TRAINING_GROK_BROKER_CONTROL_SOCKET_ENV, TRAINING_GROK_GRANT_HOME_ROOT_ENV } from "./entrypoint.js"; import { parseTrainingBrokerDeclaration } from "./declaration.js"; import { brokerProcessPlan } from "./processes.js"; -import { TRAINING_REALM_MOUNT, TRAINING_SLOT_ROOT } from "./paths.js"; +import { TRAINING_REALM_MOUNT, trainingSlotPaths, trainingEvaluatorPaths, trainingWipeTargets, trainingClearTargets } from "./paths.js"; const declaration = (overrides: Record = {}) => parseTrainingBrokerDeclaration({ - version: "spawnfile.training-broker.v1", engine: "grok", agentId: "agent:author", model: "grok-4.6", + version: "spawnfile.training-broker.v2", engine: "grok", agentId: "agent:author", model: "grok-4.6", reasoningEffort: "low", architecture: "arm64", limits: { maxRequests: 32, maxTokens: 300_000, timeoutMs: 240_000 }, bootstrap: "/var/lib/spawnfile/daimon/grok-bootstrap-auth", organizationUid: 2000, seccompProfileSha256: "f".repeat(64), ...overrides }); + describe("training broker declaration", () => { it("defaults to refusing a deny path on a filesystem that ignores unix ownership", () => { expect(declaration().unenforcedBindPolicy).toBe("refuse"); @@ -76,3 +77,5 @@ describe("training container privilege model", () => { expect(TRAINING_GROK_GRANT_HOME_ROOT_ENV).toBe("PAIDEIA_GROK_GRANT_HOME_ROOT"); }); }); + +const { root: TRAINING_SLOT_ROOT } = trainingSlotPaths(0); diff --git a/src/compiler/training/broker/entrypoint.ts b/src/compiler/training/broker/entrypoint.ts index c7f961e6..e11bf6ab 100644 --- a/src/compiler/training/broker/entrypoint.ts +++ b/src/compiler/training/broker/entrypoint.ts @@ -1,7 +1,11 @@ -import { spawn } from "node:child_process"; +import { execFile, spawn } from "node:child_process"; import { randomBytes } from "node:crypto"; import { lstat, readFile } from "node:fs/promises"; import os from "node:os"; +import { promisify } from "node:util"; +import { renderTrainingBrokerProvisioning } from "./provisioning.js"; +import { startBrokerProcesses, stopBrokerProcesses } from "./processes.js"; +import { resetTrainingBrokerSlotAsOrganization } from "./reset.js"; import path from "node:path"; import { SpawnfileError } from "../../../shared/index.js"; @@ -12,6 +16,7 @@ import { resolveTrainingGrokRegistration } from "./registration.js"; import { createTrainingSlotRuntime } from "./runtime.js"; import { createTrainingSlotSupervisor, serveTrainingSlotSupervisor, startTrainingSlot } from "./supervisor.js"; import { + trainingSlotDescriptors, assertTrainingSlotsDocument, TRAINING_SLOTS_FILE, DAIMON_ORGANIZATION_UID, TRAINING_BOOTSTRAP_MOUNT, TRAINING_BROKER_DECLARATION_FILE, @@ -77,23 +82,29 @@ export const runTrainingBrokerEntrypoint = async (options: TrainingEntrypointOpt if (process.getuid?.() !== 0) throw new SpawnfileError("runtime_error", "The broker-capable training container must start as root"); const declaration = parseTrainingBrokerDeclaration(JSON.parse(await readFile(options.declarationPath ?? TRAINING_BROKER_DECLARATION_FILE, "utf8"))); await assertBootstrap(declaration); - const registration = resolveTrainingGrokRegistration(declaration); + const descriptors = trainingSlotDescriptors(declaration.slots); + const document = JSON.parse(await readFile(TRAINING_SLOTS_FILE, "utf8")); + assertTrainingSlotsDocument(document, declaration.slots); + const registrations = descriptors.map(p => resolveTrainingGrokRegistration({ ...declaration, slot: p.slot })); const daimon = await (options.loadDaimon ?? loadDaimonProjectionModule)(); - // Daimon's projection is I/O-free, so the digest the receipt will bind is known before anything is provisioned. - const { projectionSha256 } = await resolveTrainingGrokProjection(declaration, registration, daimon); - const runtime = createTrainingSlotRuntime({ declaration, registration, projectionSha256 }); - // Provision, start, canary and publish the slot preflight receipt before `train` exists at all: the - // first trial must not be the one trial that runs on no worker-uid denial evidence, and a refusal - // here costs nothing while the same refusal after the first wake costs that trial's spend. - const preflight = await startTrainingSlot(runtime, randomBytes(32).toString("hex")); - const supervisor = createTrainingSlotSupervisor({ runtime, organizationUid: declaration.organizationUid }); - const server = serveTrainingSlotSupervisor(supervisor, TRAINING_SUPERVISOR_SOCKET, log, declaration.organizationUid); - log(`slot ${registration.slot} provisioned for ${registration.agentId} (${registration.model}/${registration.reasoningEffort}), projection ${projectionSha256}, ` - + `generation ${preflight.generation} with ${preflight.canaries} denied canaries at ${preflight.receipt}`); - const status = await runTrainingChild(options.argv, declaration, log); - server.close(); - await runtime.stop(); - return status; + const reset = resetTrainingBrokerSlotAsOrganization; + const runtimes = await Promise.all(registrations.map(async registration => { + const { projectionSha256 } = await resolveTrainingGrokProjection(declaration, registration, daimon); + return createTrainingSlotRuntime({ declaration, registration, projectionSha256, reset }); + })); + await promisify(execFile)("/bin/bash", ["--noprofile", "--norc", "-ceu", renderTrainingBrokerProvisioning(registrations, declaration.maxRunRequests).join("\n")], { maxBuffer: 8 * 1024 * 1024 }); + const children = await startBrokerProcesses({ log }); + let server: ReturnType | undefined; + try { + for (const runtime of runtimes) await startTrainingSlot(runtime, randomBytes(32).toString("hex")); + const supervisor = createTrainingSlotSupervisor({ runtimes, organizationUid: declaration.organizationUid }); + server = serveTrainingSlotSupervisor(supervisor, TRAINING_SUPERVISOR_SOCKET, log, declaration.organizationUid); + log(`provisioned ${registrations.length} training slots with one broker process set`); + return await runTrainingChild(options.argv, declaration, log); + } finally { + server?.close(); + await stopBrokerProcesses(children, log); + } }; /** @@ -121,6 +132,7 @@ const runTrainingChild = (argv: readonly string[], declaration: TrainingBrokerDe ...process.env, [TRAINING_GROK_BROKER_CONTROL_SOCKET_ENV]: DAIMON_GROK_ENGINE_BROKER.controlSocketPath, [TRAINING_GROK_GRANT_HOME_ROOT_ENV]: TRAINING_GRANT_HOME_ROOT, + PAIDEIA_TRAINING_SLOTS_FILE: TRAINING_SLOTS_FILE, HOME: "/home/training" } }); diff --git a/src/compiler/training/broker/index.ts b/src/compiler/training/broker/index.ts index 09ca24d9..4b82834a 100644 --- a/src/compiler/training/broker/index.ts +++ b/src/compiler/training/broker/index.ts @@ -3,10 +3,11 @@ export { assertNotDesktopGrokAuth, desktopGrokAuthPaths, runTrainingBrokerEntryp TRAINING_GROK_BROKER_CONTROL_SOCKET_ENV, TRAINING_GROK_GRANT_HOME_ROOT_ENV } from "./entrypoint.js"; export { brokerProcessPlan, startBrokerProcesses, stopBrokerProcesses } from "./processes.js"; export { loadDaimonProjectionModule, resolveTrainingGrokProjection, trainingOrganizationRuntimeConfig } from "./projection.js"; -export { renderTrainingBrokerProvisioning, renderTrainingIdentities, trainingSlotDirectories } from "./provisioning.js"; +export { renderTrainingBrokerProvisioning, renderTrainingIdentities, renderTrainingSlotProvisioning, trainingSlotDirectories } from "./provisioning.js"; export { buildTrainingSlotReceipt, readGrokExecutableSha256, resolveBackingFilesystem, resolveTrainingCanaries } from "./receipt.js"; export { resolveTrainingGrokDenyPaths, resolveTrainingGrokRegistration } from "./registration.js"; -export { createTrainingSlotRuntime } from "./runtime.js"; +export { resetTrainingBrokerSlot, resetTrainingBrokerSlotAsOrganization } from "./reset.js"; +export { createTrainingSlotRuntime, renderTrainingSlotWipe } from "./runtime.js"; export { assertTrainingSupervisorSocketIdentity, createTrainingSlotSupervisor, serveTrainingSlotSupervisor, TRAINING_SUPERVISOR_PROTOCOL, type TrainingSlotRuntime } from "./supervisor.js"; export * from "./paths.js"; diff --git a/src/compiler/training/broker/main.ts b/src/compiler/training/broker/main.ts index a7e84452..8a6629d2 100644 --- a/src/compiler/training/broker/main.ts +++ b/src/compiler/training/broker/main.ts @@ -3,7 +3,7 @@ import { runTrainingBrokerEntrypoint } from "./entrypoint.js"; /** * The broker-capable training image's root entrypoint process. The image ships - * it; the host supplies only the read-only `spawnfile.training-broker.v1` + * it; the host supplies only the read-only `spawnfile.training-broker.v2` * declaration and never a script, so no host executable enters the container. */ const main = async (): Promise => { diff --git a/src/compiler/training/broker/paths.parallel.test.ts b/src/compiler/training/broker/paths.parallel.test.ts new file mode 100644 index 00000000..1976e628 --- /dev/null +++ b/src/compiler/training/broker/paths.parallel.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; +import path from "node:path"; +import { z } from "zod"; +import { assertTrainingSlotsDocument, assertTrainingSlotCount, trainingSlotDescriptors, trainingSlotPaths, trainingSlotsDocument, trainingWipeTargets, trainingClearTargets, TRAINING_RUN_REQUEST_STORE, TRAINING_BROKER_SOCKET_PATHS } from "./paths.js"; +import { resolveTrainingGrokRegistration, resolveTrainingGrokDenyPaths } from "./registration.js"; +import { renderTrainingBrokerProvisioning, renderTrainingSlotProvisioning } from "./provisioning.js"; +import { renderDaimonGrokServiceConfig } from "../../containerDaimonGrokWorkerRender.js"; +import { trainingBrokerTmpfsTargets } from "../container/security.js"; + +const registrations = () => trainingSlotDescriptors().map(({ slot }) => resolveTrainingGrokRegistration({ + agentId: "agent:author", model: "grok-4.6", reasoningEffort: "low", slot, slots: 4 +})); + +describe("four independent training slots", () => { + it("defaults to four distinct identities, canonical paths, port blocks and clock files", () => { + const document = trainingSlotsDocument(); + expect(document.schema).toBe("spawnfile.training-slots.v1"); + expect(document.slots).toHaveLength(4); + expect(document.slots.map(p => p.index)).toEqual([0, 1, 2, 3]); + expect(document.slots.every(p => p.ports.count === 100)).toBe(true); + const slots = trainingSlotDescriptors(); + expect(document.slots.map(p => p.workerUid)).toEqual([2200, 2201, 2202, 2203]); + expect(document.slots.map(p => p.ports.base)).toEqual([20000, 20100, 20200, 20300]); + for (const field of ["workspace", "runtimeHome", "state", "turns", "usageLedger", "workerHome", "preflightReceipt", "sealedInputsAttestation", "privateTmp", "clockFile"] as const) { + expect(new Set(slots.map(p => p[field])).size, field).toBe(4); + } + for (const p of slots) { + expect(p.root).toBe(`/run/training/slots/${p.slot}`); + expect(p.clockFile).toBe(`/run/daimon-engine-broker/task-clock/slot-${p.slot}/faketime`); + expect(p.ports.workerControl).toBe(p.ports.base + 1); + expect(p.ports.bridge).toBe(p.ports.base + 2); + } + }); + + it("rejects invalid counts and indexes, including a valid index outside the pool", () => { + for (const n of [0, -1, 9, 1.5, NaN, Infinity]) { + expect(() => assertTrainingSlotCount(n)).toThrow(); + expect(() => trainingSlotDescriptors(n)).toThrow(); + } + for (const n of [-1, 8, 0.5, NaN, Infinity]) expect(() => trainingSlotPaths(n)).toThrow(); + expect(() => resolveTrainingGrokDenyPaths([], 4, 4)).toThrow(/outside/); + expect(trainingSlotDescriptors(1)).toHaveLength(1); + expect(trainingSlotDescriptors(8)).toHaveLength(8); + }); + + it("mandatory sibling masks cannot be removed through an empty custom deny list", () => { + const slots = trainingSlotDescriptors(); + for (const p of slots) { + const denied = resolveTrainingGrokDenyPaths([], p.slot, 4); + expect(denied).toEqual([...denied].sort()); + for (const sibling of slots.filter(other => other.slot !== p.slot)) { + expect(denied).toContain(sibling.root); + expect(denied).toContain(sibling.workerHome); + expect(denied).toContain(sibling.clockDirectory); + } + for (const own of [p.workspace, p.runtimeHome, p.workerHome, p.clockFile]) { + expect(denied.some(d => own === d || own.startsWith(`${d}/`))).toBe(false); + } + for (const d of denied) expect(denied.some(other => other !== d && d.startsWith(`${other}/`))).toBe(false); + } + }); + + it("renders one broker configuration with four repeated-agent registrations and separate policies", () => { + const rows = registrations(); + for (const row of rows) { + expect(row.config).toContain("[shell_environment_policy]"); + expect(row.config).toContain(trainingSlotPaths(row.slot).clockFile); + for (const other of rows.filter(other => other.slot !== row.slot)) expect(row.config).not.toContain(trainingSlotPaths(other.slot).clockFile); + } + const config = renderDaimonGrokServiceConfig(rows, { version: "v3", turnStore: "/run/training/turns" }); + expect(config.version).toBe("noopolis.daimon.engine-broker-service.v3"); + expect(config.registrations.map(r => r.agentId)).toEqual(Array(4).fill("agent:author")); + expect(config.registrations.map(r => r.slot)).toEqual([0, 1, 2, 3]); + for (const r of config.registrations) expect(Object.keys(r).sort()).toEqual(["agentId", "slot", "workerUid", "workspace", "profilePath", "eventsPath", "profileSha256"].sort()); + expect(config.slotPolicies?.map(p => p.usageLedgerPath)).toEqual(rows.map(r => r.usageLedgerPath)); + const script = renderTrainingBrokerProvisioning(rows, 17).join("\n"); + const serviceLine = script.split("\n").find(line => line.startsWith("const service = "))!; + 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'"); + for (const socket of TRAINING_BROKER_SOCKET_PATHS) { + 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); + } + expect(script.match(/const registrations = /g)).toHaveLength(1); + expect(script.match(/const service = /g)).toHaveLength(1); + expect(script).toContain("worker.slot === registration.slot"); + for (const p of trainingSlotDescriptors()) { + expect(resolveTrainingGrokDenyPaths([], p.slot, 4)).toContain(TRAINING_RUN_REQUEST_STORE); + expect([...trainingWipeTargets(p.slot), ...trainingClearTargets(p.slot)].some(root => + TRAINING_RUN_REQUEST_STORE === root || TRAINING_RUN_REQUEST_STORE.startsWith(`${root}/`))).toBe(false); + expect(script).toContain(p.clockFile); + expect(script).toContain(p.sealedInputsAttestation); + } + }); + + it("reprovisions only the selected worker without touching shared service, realm, home or temp identities", () => { + const script = renderTrainingSlotProvisioning(registrations()[1]!).join("\n"); + expect(script).not.toContain("const service ="); + expect(script).not.toContain("const shared of"); + expect(script).not.toContain("ensureDirectory(entry.home,"); + expect(script).not.toContain("ensureDirectory(entry.privateTmp,"); + expect(script).not.toContain("credential-journal"); + expect(script).toContain(trainingSlotPaths(1).sealedInputsAttestation); + expect(script).not.toContain(trainingSlotPaths(0).sealedInputsAttestation); + expect(script).toContain('"slot":1'); + expect(script).not.toContain('"slot":0'); + }); + + it("keeps every mount outside every slot wipe target and descendant of a clear target", () => { + const mounts = trainingBrokerTmpfsTargets(4).map(p => p.path); + for (const p of trainingSlotDescriptors()) { + expect(mounts).toContain(p.root); + for (const target of trainingWipeTargets(p.slot)) expect(mounts.some(m => m === target || m.startsWith(`${target}/`))).toBe(false); + for (const target of trainingClearTargets(p.slot)) expect(mounts.some(m => m !== target && m.startsWith(`${target}/`))).toBe(false); + expect(trainingWipeTargets(p.slot)).not.toContain(p.workerHome); + expect(trainingWipeTargets(p.slot)).not.toContain(p.privateTmp); + } + }); +}); + +it("publishes Paideia's strict runtime-neutral descriptor shape with every detailed path preserved", () => { + const consumerContract = z.strictObject({ schema: z.literal("spawnfile.training-slots.v1"), slots: z.array(z.strictObject({ + index: z.number().int().min(0).max(7), workerUid: z.number().int().positive(), + roots: z.record(z.string().min(1), z.string().refine(path.isAbsolute)), + ports: z.strictObject({ base: z.number().int().min(1024).max(65535), count: z.number().int().positive() }) + })).min(1).max(8) }); + const document = trainingSlotsDocument(); + expect(consumerContract.parse(document)).toEqual(document); + for (const descriptor of trainingSlotDescriptors()) { + const slot = document.slots[descriptor.slot]!; + expect(slot.roots.workspace).toBe(descriptor.workspace); + expect(slot.roots.home).toBe(descriptor.runtimeHome); + expect(slot.roots.control).toBe(descriptor.state); + expect(slot.roots.clockFile).toBe(descriptor.clockFile); + for (const [index, entry] of descriptor.evaluatorPaths.entries()) expect(slot.roots[`evaluator:${entry.role}:${index}`]).toBe(entry.path); + for (const [index, entry] of descriptor.containerPaths.entries()) expect(slot.roots[`container:${index}`]).toBe(entry.path); + } +}); + +it("refuses an edited, stale or extra-member slots bind before provisioning", () => { + const document = trainingSlotsDocument(4); + expect(() => assertTrainingSlotsDocument(document, 4)).not.toThrow(); + expect(() => assertTrainingSlotsDocument(document, 3)).toThrow(/differs/); + expect(() => assertTrainingSlotsDocument({ ...document, extra: true }, 4)).toThrow(/differs/); + document.slots[1]!.roots.workspace = document.slots[0]!.roots.workspace!; + expect(() => assertTrainingSlotsDocument(document, 4)).toThrow(/differs/); +}); diff --git a/src/compiler/training/broker/paths.test.ts b/src/compiler/training/broker/paths.test.ts index 1f03272b..8f08937e 100644 --- a/src/compiler/training/broker/paths.test.ts +++ b/src/compiler/training/broker/paths.test.ts @@ -4,12 +4,7 @@ import { DAIMON_GROK_ENGINE_BROKER } from "../../../runtime/daimon/contractManif import { trainingBrokerMounts, trainingBrokerTmpfsTargets } from "../container/security.js"; import { renderTrainingBrokerProvisioning, trainingSlotDirectories } from "./provisioning.js"; import { resolveTrainingGrokRegistration } from "./registration.js"; -import { - TRAINING_BROKER_TMPDIR, - TRAINING_CLEAR_TARGETS, - TRAINING_SLOT_RUNTIME_HOME, - TRAINING_WIPE_TARGETS -} from "./paths.js"; +import { TRAINING_BROKER_TMPDIR, trainingSlotPaths, trainingEvaluatorPaths, trainingWipeTargets, trainingClearTargets } from "./paths.js"; /** Every path the v3 launch mounts: its tmpfs set plus the realm volume and the two read-only binds. */ const launchMounts = (): string[] => [ @@ -20,6 +15,8 @@ const launchMounts = (): string[] => [ const at_or_below = (candidate: string, root: string): boolean => candidate === root || candidate.startsWith(`${root}/`); +const TRAINING_WIPE_TARGETS = trainingWipeTargets(0); +const TRAINING_CLEAR_TARGETS = trainingClearTargets(0); describe("training wipe targets versus the launch's own mounts", () => { /** * The P8 regression, as an invariant rather than an incident. A mount point @@ -94,3 +91,5 @@ describe("the traversable slot runtime home", () => { .toEqual({ owner: "organization", group: "worker", mode: 0o710 }); }); }); + +const { runtimeHome: TRAINING_SLOT_RUNTIME_HOME } = trainingSlotPaths(0); diff --git a/src/compiler/training/broker/paths.ts b/src/compiler/training/broker/paths.ts index a029a0b9..5ae752fd 100644 --- a/src/compiler/training/broker/paths.ts +++ b/src/compiler/training/broker/paths.ts @@ -16,7 +16,7 @@ import { DAIMON_WORKER_ROOT } from "../../containerDaimonGrokWorkerRender.js"; /** * Every container path the broker-capable training container fixes. * - * They are fixed rather than derived because three parties must agree on them + * They are deterministic because three parties must agree on them * without talking to each other: the root entrypoint that provisions them, the * root slot supervisor that wipes and re-provisions them on recycle, and * Paideia's native adapter, which writes them into `paideia.daimon-native.launch.v2` @@ -24,7 +24,7 @@ import { DAIMON_WORKER_ROOT } from "../../containerDaimonGrokWorkerRender.js"; * resolves a path, so each one must also be canonical and never a symlink; the * provisioning program asserts that before a slot is used. * - * Everything under `/run/training/slot` is per-trial state on tmpfs — never the + * Everything under `/run/training/slots/` is per-trial state on tmpfs — never the * Grok realm volume (R2). The realm volume holds only `auth.json` and the * broker credential journal, so a recycle can wipe the slot without touching * the one durable rotating credential. @@ -33,22 +33,9 @@ export const TRAINING_RUN_ROOT = "/run/training/output"; export const TRAINING_SEALED_INPUTS_ROOT = "/run/training/inputs"; export const TRAINING_PAIDEIA_ROOT = "/run/paideia"; export const TRAINING_CONTEXT_FILE = `${TRAINING_PAIDEIA_ROOT}/context.json`; -/** Host-written, read-only `spawnfile.training-broker.v1` declaration the root entrypoint reads. */ +/** Host-written, read-only `spawnfile.training-broker.v2` declaration the root entrypoint reads. */ export const TRAINING_BROKER_DECLARATION_FILE = `${TRAINING_PAIDEIA_ROOT}/training-broker.json`; -export const TRAINING_SLOT_ROOT = "/run/training/slot"; -export const TRAINING_SLOT_WORKSPACE = `${TRAINING_SLOT_ROOT}/workspace`; -export const TRAINING_SLOT_RUNTIME_HOME = `${TRAINING_SLOT_ROOT}/runtime-home`; -export const TRAINING_SLOT_STATE_ROOT = `${TRAINING_SLOT_ROOT}/state`; -export const TRAINING_SLOT_ACCEPTANCE_STORE = `${TRAINING_SLOT_STATE_ROOT}/wake-acceptance`; -/** Per-slot turn store. On tmpfs on purpose: a replayed turn from trial N must never satisfy trial N+1 (R2). */ -export const TRAINING_SLOT_TURN_STORE = `${TRAINING_SLOT_ROOT}/turns`; -export const TRAINING_SLOT_USAGE_DIRECTORY = `${TRAINING_SLOT_ROOT}/usage`; -export const TRAINING_SLOT_USAGE_LEDGER = `${TRAINING_SLOT_USAGE_DIRECTORY}/usage.jsonl`; -export const TRAINING_SLOT_PREFLIGHT_RECEIPT = `${TRAINING_SLOT_ROOT}/preflight.json`; -/** Supervisor-owned monotonic generation counter; survives a recycle, never a container restart. */ -export const TRAINING_SLOT_GENERATION_FILE = `${TRAINING_SLOT_ROOT}/generation.json`; - export const TRAINING_INFERENCE_DIRECTORY = "/run/training/inference"; export const TRAINING_INFERENCE_LEDGER = `${TRAINING_INFERENCE_DIRECTORY}/inference.jsonl`; /** Judge grant homes: `2000:2000 0700`, denied to every worker uid. Paideia reads it as `PAIDEIA_GROK_GRANT_HOME_ROOT`. */ @@ -73,13 +60,6 @@ export const TRAINING_SUPERVISOR_SOCKET = `${TRAINING_SUPERVISOR_DIRECTORY}/cont export const TRAINING_SUPERVISOR_LOG = `${TRAINING_SUPERVISOR_DIRECTORY}/supervisor.log`; export const TRAINING_WORKER_ROOT = DAIMON_WORKER_ROOT; -export const TRAINING_SLOT_INDEX = 0; -export const TRAINING_WORKER_UID = DAIMON_FIRST_WORKER_UID + TRAINING_SLOT_INDEX; -export const TRAINING_WORKER_HOME = path.posix.join(TRAINING_WORKER_ROOT, String(TRAINING_WORKER_UID)); -/** Keep /run's read-only directory mount visible for atomic clock-file replacement. */ -export const TRAINING_BROKER_SOCKET_PATHS = [DAIMON_GROK_ENGINE_BROKER.controlSocketPath, - DAIMON_GROK_ENGINE_BROKER.backendSocketPath, DAIMON_GROK_ENGINE_BROKER.launcherSocketPath] as const; - /** * Paideia's own fixed container paths (`containerPaths` in its native launch * schema). They are *not* individual deny entries: Grok 1.0.34 materializes @@ -114,68 +94,101 @@ export const TRAINING_CALLER_PROTECTED_PATHS = [ * tagged `context` — the caller-state role — since there is no other way to * carry a path through that contract. */ -export const TRAINING_EVALUATOR_PATHS: readonly { role: "run-root" | "context" | "sealed-inputs" | "judge-home" | "slot-ledger"; path: string }[] = [ - { role: "run-root", path: TRAINING_RUN_ROOT }, - { role: "sealed-inputs", path: TRAINING_SEALED_INPUTS_ROOT }, - { role: "judge-home", path: TRAINING_GRANT_HOME_ROOT }, - { role: "slot-ledger", path: TRAINING_SLOT_USAGE_DIRECTORY }, - { role: "context", path: TRAINING_PAIDEIA_ROOT }, - { role: "context", path: TRAINING_BROKER_TMPDIR }, - { role: "context", path: TRAINING_INFERENCE_DIRECTORY }, - { role: "context", path: TRAINING_SLOT_TURN_STORE }, - { role: "context", path: TRAINING_SUPERVISOR_DIRECTORY }, - ...TRAINING_BROKER_SOCKET_PATHS.map((socket) => ({ role: "context" as const, path: socket })), - { role: "context", path: path.posix.dirname(DAIMON_GROK_ENGINE_BROKER.registrationPath) }, - { role: "context", path: DAIMON_GROK_TURN_USAGE_LEDGER.directoryPath }, - { role: "context", path: DAIMON_WAKE_FUSE_DIRECTORY } -]; +export const TRAINING_SLOTS_FILE = `${TRAINING_PAIDEIA_ROOT}/slots.json`; +export const TRAINING_SLOTS_VERSION = "spawnfile.training-slots.v1" as const; +export const TRAINING_DEFAULT_SLOTS = 4; +export const TRAINING_MAX_SLOTS = 8; +/** 100 ports per slot: offsets 0=Moltnet, 1=worker control, 2=bridge; 3..99 reserved. */ +export const TRAINING_PORT_BASE = 20_000; +export const TRAINING_PORT_STRIDE = 100; +export const TRAINING_BROKER_SOCKET_PATHS = [ + DAIMON_GROK_ENGINE_BROKER.controlSocketPath, DAIMON_GROK_ENGINE_BROKER.backendSocketPath, + DAIMON_GROK_ENGINE_BROKER.launcherSocketPath +] as const; +export const TRAINING_TURN_STORE_ROOT = "/run/training/turns"; +/** Persistent, broker-only run counter; never part of a slot recycle. */ +export const TRAINING_RUN_REQUEST_STORE = "/run/training/request-budget"; -/** - * One entry per evaluator role, in declaration order: the launch receipt requires all five roles, - * and several `context` paths share that role, so the first of each role is the role's root. - */ -export const TRAINING_EVALUATOR_ROOTS = TRAINING_EVALUATOR_PATHS - .filter((entry, index) => TRAINING_EVALUATOR_PATHS.findIndex((first) => first.role === entry.role) === index); +export const assertTrainingSlotCount = (slots: number): number => { + if (!Number.isInteger(slots) || slots < 1 || slots > TRAINING_MAX_SLOTS) throw Error("Training slots must be an integer from 1 to 8"); + return slots; +}; +export const trainingSlotPaths = (slot: number) => { + if (!Number.isInteger(slot) || slot < 0 || slot >= TRAINING_MAX_SLOTS) throw Error("Training slot index must be an integer from 0 to 7"); + const root = `/run/training/slots/${slot}`; + const workerUid = DAIMON_FIRST_WORKER_UID + slot; + const workerHome = `${TRAINING_WORKER_ROOT}/${workerUid}`; + const portBase = TRAINING_PORT_BASE + slot * TRAINING_PORT_STRIDE; + const clockDirectory = `/run/daimon-engine-broker/task-clock/slot-${slot}`; + return { + slot, workerUid, root, workerHome, workspace: `${root}/workspace`, runtimeHome: `${root}/runtime-home`, + state: `${root}/state`, acceptanceStore: `${root}/state/wake-acceptance`, + turns: `${TRAINING_TURN_STORE_ROOT}/slot-${slot}`, usageDirectory: `${root}/usage`, usageLedger: `${root}/usage/usage.jsonl`, + preflightReceipt: `${root}/preflight.json`, generationFile: `${root}/generation.json`, + sealedInputsAttestation: `${root}/sealed-inputs.json`, privateTmp: `${workerHome}/tmp`, + clockDirectory, clockFile: `${clockDirectory}/faketime`, + ports: { base: portBase, stride: TRAINING_PORT_STRIDE, moltnet: portBase, workerControl: portBase + 1, bridge: portBase + 2 } + }; +}; +export type TrainingSlotPaths = ReturnType; +export type TrainingEvaluatorPath = { role: "run-root" | "context" | "sealed-inputs" | "judge-home" | "slot-ledger"; path: string }; -/** - * What `paideia.daimon-native.launch.v2`'s `controlRoot` must be. - * - * Paideia's worker passes it to Daimon as `acceptanceStorePath`, and Daimon adds - * it to the projection's deny list, so it has to be the same path this - * container masks as its slot state root. Its own default - * (`/run/paideia/control`) would both change the digest and nest inside the - * `/run/paideia` mask, which Grok refuses — Daimon renders a nested mask - * without complaining, so the failure would only appear at the first turn. - */ -export const TRAINING_CALLER_CONTROL_ROOT = TRAINING_SLOT_STATE_ROOT; +/** The complete additional projection deny set. Sibling roots and homes never nest with own masks. */ +export const trainingEvaluatorPaths = (slot: number, slots: number): TrainingEvaluatorPath[] => { + assertTrainingSlotCount(slots); + if (slot >= slots) throw Error("Training slot is outside the declared pool"); + const own = trainingSlotPaths(slot); + return [ + { role: "run-root", path: TRAINING_RUN_ROOT }, + { role: "sealed-inputs", path: TRAINING_SEALED_INPUTS_ROOT }, + { role: "judge-home", path: TRAINING_GRANT_HOME_ROOT }, + { role: "slot-ledger", path: own.usageDirectory }, + ...[TRAINING_PAIDEIA_ROOT, TRAINING_BROKER_TMPDIR, TRAINING_INFERENCE_DIRECTORY, + TRAINING_TURN_STORE_ROOT, TRAINING_RUN_REQUEST_STORE, TRAINING_SUPERVISOR_DIRECTORY, + ...TRAINING_BROKER_SOCKET_PATHS, + path.posix.dirname(DAIMON_GROK_ENGINE_BROKER.registrationPath), + DAIMON_GROK_TURN_USAGE_LEDGER.directoryPath, DAIMON_WAKE_FUSE_DIRECTORY, + ...Array.from({ length: slots }, (_, i) => trainingSlotPaths(i)).filter(p => p.slot !== slot) + .flatMap(p => [p.root, p.workerHome, p.clockDirectory]) + ].map(path => ({ role: "context" as const, path })) + ]; +}; +export const trainingSlotDescriptors = (slots = TRAINING_DEFAULT_SLOTS) => + Array.from({ length: assertTrainingSlotCount(slots) }, (_, slot) => ({ + ...trainingSlotPaths(slot), evaluatorPaths: trainingEvaluatorPaths(slot, slots), + containerPaths: TRAINING_CALLER_PROTECTED_PATHS.map(path => ({ path })), + controlRoot: trainingSlotPaths(slot).state + })); +export type TrainingSlotDescriptor = ReturnType[number]; +export interface TrainingSlotsDocument { + schema: typeof TRAINING_SLOTS_VERSION; + slots: { index: number; workerUid: number; roots: Record; ports: { base: number; count: number } }[]; +} +export const trainingSlotsDocument = (slots = TRAINING_DEFAULT_SLOTS): TrainingSlotsDocument => ({ + schema: TRAINING_SLOTS_VERSION, + slots: trainingSlotDescriptors(slots).map(p => ({ + index: p.slot, workerUid: p.workerUid, + // Paideia owns a runtime-neutral lease pool: its strict descriptor carries opaque named paths. + roots: { + ...Object.fromEntries(Object.entries(p).filter((entry): entry is [string, string] => typeof entry[1] === "string")), + home: p.runtimeHome, control: p.state, context: TRAINING_PAIDEIA_ROOT, + ...Object.fromEntries(p.evaluatorPaths.map((entry, index) => [`evaluator:${entry.role}:${index}`, entry.path])), + ...Object.fromEntries(p.containerPaths.map((entry, index) => [`container:${index}`, entry.path])) + }, + ports: { base: p.ports.base, count: p.ports.stride } + })) +}); -/** - * Everything this container provisions that the one training worker must not - * read, beyond Daimon's own protected set (realm, bootstrap, acceptance store). - * Each entry sits strictly below a Grok 1.0.34 base-profile grant — Grok - * refuses a profile whose deny entry equals or contains `/run`, `/var`, `/etc` - * or `/tmp` — and no entry covers another, because masks cannot nest. - */ -/** - * The added deny set is exactly the launch's `evaluatorPaths`, so Spawnfile and - * Paideia hand Daimon's resolver the same list. `TRAINING_SLOT_STATE_ROOT` is - * deliberately absent: Daimon adds it itself, from the control root above. - */ -export const TRAINING_ADDED_DENY_PATHS: readonly string[] = - [...new Set(TRAINING_EVALUATOR_PATHS.map((entry) => entry.path))].sort(); +/** Both sides derive the descriptor bytes; a stale or edited bind must never certify a different pool. */ +export const assertTrainingSlotsDocument = (value: unknown, slots: number): void => { + if (JSON.stringify(value) !== JSON.stringify(trainingSlotsDocument(slots))) throw Error("Training slots manifest differs from the broker declaration"); +}; -/** Paths the provisioning program creates root-owned `0700` when absent, so every deny entry always has a target inode. */ +/** Shared deny targets exist before worker attestation; no per-slot subtree is touched here. */ export const TRAINING_OPTIONAL_DENY_DIRECTORIES: readonly string[] = [ - TRAINING_BROKER_TMPDIR, - TRAINING_INFERENCE_DIRECTORY, - TRAINING_SLOT_STATE_ROOT, - TRAINING_SLOT_TURN_STORE, - TRAINING_SUPERVISOR_DIRECTORY, - TRAINING_GRANT_HOME_ROOT, - // Denied and unused here — training meters per slot — but the mask still needs an inode. The launch - // mounts each as tmpfs, so this only creates them when a caller ran the entrypoint without them. - DAIMON_GROK_TURN_USAGE_LEDGER.directoryPath, - DAIMON_WAKE_FUSE_DIRECTORY + TRAINING_BROKER_TMPDIR, TRAINING_INFERENCE_DIRECTORY, TRAINING_TURN_STORE_ROOT, + TRAINING_SUPERVISOR_DIRECTORY, TRAINING_GRANT_HOME_ROOT, + DAIMON_GROK_TURN_USAGE_LEDGER.directoryPath, DAIMON_WAKE_FUSE_DIRECTORY ]; /** @@ -236,7 +249,7 @@ export const TRAINING_HOST_BIND_DENY_PATHS: readonly string[] = [TRAINING_RUN_RO export const TRAINING_SEALED_DENY_PATHS: readonly string[] = [TRAINING_SEALED_INPUTS_ROOT]; /** Spawnfile-owned per-route seal attestation, written by provisioning beside the slot preflight receipt. */ -export const TRAINING_SEALED_INPUTS_ATTESTATION = `${TRAINING_SLOT_ROOT}/sealed-inputs.json`; + /** The image-baked identity of the sealed inputs root, asserted before every slot and never writable at runtime. */ export const TRAINING_SEALED_INPUTS_IDENTITY = { uid: 0, gid: DAIMON_ORGANIZATION_UID, mode: "750" } as const; @@ -252,22 +265,15 @@ export const TRAINING_SEALED_INPUTS_IDENTITY = { uid: 0, gid: DAIMON_ORGANIZATIO * anyway, so an undeclared one degrades to "left in place and reported" rather * than a failed provision. */ -export const TRAINING_WIPE_TARGETS: readonly string[] = [ - TRAINING_WORKER_HOME, - TRAINING_SLOT_RUNTIME_HOME, - TRAINING_SLOT_TURN_STORE, - TRAINING_SLOT_ACCEPTANCE_STORE, - DAIMON_GROK_ENGINE_BROKER.serviceConfigPath, - DAIMON_GROK_ENGINE_BROKER.registrationPath -]; - -/** Emptied but kept: their paths are registered with Daimon and must stay canonical across a recycle. */ -export const TRAINING_CLEAR_TARGETS: readonly string[] = [ - TRAINING_SLOT_WORKSPACE, - TRAINING_SLOT_USAGE_DIRECTORY, - path.posix.dirname(DAIMON_GROK_ENGINE_BROKER.controlSocketPath), - path.posix.dirname(DAIMON_GROK_ENGINE_BROKER.registrationPath) -]; +export const trainingWipeTargets = (slot: number): readonly string[] => { + const p = trainingSlotPaths(slot); + return [p.runtimeHome, p.state, p.preflightReceipt, p.sealedInputsAttestation]; +}; +/** Broker-owned turns and clocks are reset through its control seam, never by filesystem deletion. */ +export const trainingClearTargets = (slot: number): readonly string[] => { + const p = trainingSlotPaths(slot); + return [p.workspace, p.usageDirectory]; +}; export const TRAINING_REALM_MOUNT = DAIMON_GROK_SUBSCRIPTION_REALM.durableMountPath; export const TRAINING_BOOTSTRAP_MOUNT = DAIMON_GROK_SUBSCRIPTION_REALM.bootstrapMountPath; diff --git a/src/compiler/training/broker/projection.integration.test.ts b/src/compiler/training/broker/projection.integration.test.ts new file mode 100644 index 00000000..bd102127 --- /dev/null +++ b/src/compiler/training/broker/projection.integration.test.ts @@ -0,0 +1,46 @@ +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { describe, expect, it } from "vitest"; +import { DAIMON_GROK_ENGINE_BROKER } from "../../../runtime/daimon/contractManifest.js"; +import { parseTrainingBrokerDeclaration } from "./declaration.js"; +import { trainingSlotDescriptors } from "./paths.js"; +import { resolveTrainingGrokRegistration } from "./registration.js"; +import { resolveTrainingGrokProjection, type DaimonProjectionModule } from "./projection.js"; +import { buildTrainingSlotReceipt } from "./receipt.js"; + +// Source checkout integration is explicit: no runtime dependency on Daimon and no provider calls. +const source = process.env.SPAWNFILE_DAIMON_SOURCE_DIR; +const load = (file: string) => import(/* @vite-ignore */ pathToFileURL(path.join(source!, file)).href); + +describe.skipIf(!source)("clocked training slots against Daimon's actual contract", () => { + it("attests every model/effort and slot, and verifies the clocked projection receipt", async () => { + const [projectionApi, slotApi, configApi, receiptApi] = await Promise.all([ + load("src/runtime/grokBrokerProjection.ts"), load("src/runtime/grokBrokerSlot.ts"), + load("src/runtime/grokBrokerWorkerConfig.ts"), load("src/runtime/grokSlotPreflightReceipt.ts") + ]); + const daimon = { ...projectionApi, GROK_ENGINE_BROKER: DAIMON_GROK_ENGINE_BROKER } as DaimonProjectionModule; + for (const count of [1, 4, 8]) for (const p of trainingSlotDescriptors(count)) { + for (const model of DAIMON_GROK_ENGINE_BROKER.worker.models) for (const reasoningEffort of DAIMON_GROK_ENGINE_BROKER.worker.reasoningEfforts) { + const declaration = parseTrainingBrokerDeclaration({ version: "spawnfile.training-broker.v2", slots: count, + engine: "grok", agentId: "agent:author", model, reasoningEffort, architecture: "arm64", + limits: { maxRequests: 4, maxTokens: 100, timeoutMs: 1000 }, organizationUid: 2000, + bootstrap: "/var/lib/spawnfile/daimon/grok-bootstrap-auth", seccompProfileSha256: "a".repeat(64) }); + const registration = resolveTrainingGrokRegistration({ ...declaration, slot: p.slot }); + slotApi.assertBrokerTaskClockIsolation("clocked", p.slot, trainingSlotDescriptors(count).map(s => s.slot), registration.denyPaths); + expect(registration.config).toBe(configApi.renderGrokBrokerWorkerConfig({ model, reasoningEffort }, "clocked", p.slot)); + const { projection, projectionSha256 } = await resolveTrainingGrokProjection(declaration, registration, daimon); + expect(projection.workerConfigVariant).toBe("clocked"); + expect(projection.workerConfigSha256).toBe(registration.configSha256); + expect(projection.taskClock).toMatchObject({ timestampFilePath: p.clockFile, directoryPath: p.clockDirectory }); + const nonce = "b".repeat(64); + const receipt = buildTrainingSlotReceipt({ slot: p.slot, workerUid: p.workerUid, generation: 1, nonce, + projectionSha256, sandboxProfileSha256: registration.profileSha256, + seccompProfileSha256: declaration.seccompProfileSha256, grokExecutableSha256: projection.grokExecutableSha256 as string, + canaries: registration.denyPaths.map(path => ({ path, method: "sandboxed-read", result: "denied" })), createdAt: new Date() }); + expect(receiptApi.verifyGrokSlotPreflightReceipt(receipt, projection, { expectedNonce: nonce, minGeneration: 1 })).toEqual(receipt); + const stale = { ...registration, configSha256: "0".repeat(64) }; + await expect(resolveTrainingGrokProjection(declaration, stale, daimon)).rejects.toThrow(/clocked worker config differs/); + } + } + }); +}); diff --git a/src/compiler/training/broker/projection.test.ts b/src/compiler/training/broker/projection.test.ts index 915608c1..78020b9b 100644 --- a/src/compiler/training/broker/projection.test.ts +++ b/src/compiler/training/broker/projection.test.ts @@ -8,7 +8,7 @@ import { resolveTrainingGrokRegistration } from "./registration.js"; const broker = DAIMON_GROK_ENGINE_BROKER; const declaration = parseTrainingBrokerDeclaration({ - version: "spawnfile.training-broker.v1", engine: "grok", agentId: "agent:author", model: "grok-4.6", reasoningEffort: "low", + 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) }); @@ -32,10 +32,10 @@ const moduleFor = (overrides: Record = {}): DaimonProjectionMod }); describe("training clocked projection", () => { - it("binds the v2 variant and selected config digest into the slot receipt", async () => { + 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.v2", + 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, @@ -55,7 +55,7 @@ describe("training clocked projection", () => { 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, workerConfigVariant: "unclocked" }, moduleFor())).rejects.toThrow(/clocked worker config/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) => { diff --git a/src/compiler/training/broker/projection.ts b/src/compiler/training/broker/projection.ts index bacba19c..f8495178 100644 --- a/src/compiler/training/broker/projection.ts +++ b/src/compiler/training/broker/projection.ts @@ -6,7 +6,7 @@ import { } from "../../../runtime/daimon/contractManifest.js"; import type { DaimonGrokRegistration } from "../../containerDaimonGrokWorkerRender.js"; import type { TrainingBrokerDeclaration } from "./declaration.js"; -import { TRAINING_SLOT_RUNTIME_HOME, TRAINING_SLOT_STATE_ROOT, TRAINING_SLOT_WORKSPACE, TRAINING_WORKER_HOME } from "./paths.js"; +import { trainingSlotPaths } from "./paths.js"; /** * The slice of Daimon's public `@noopolis/daimon/runtime` export the slot @@ -32,8 +32,8 @@ 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.slotPreflightVersion !== DAIMON_GROK_ENGINE_BROKER.slotPreflightVersion || 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"); } @@ -50,19 +50,19 @@ export const loadDaimonProjectionModule = async (load: () => Promise = * of peers reach `resolveOrganizationGrokBrokerProjection`, so this minimal * config and Paideia's fuller one compute the same projection digest. A peer * agent would change Daimon's protected set and the digest with it, which is - * exactly why training runs one slot. + * why each slot resolves its own one-agent projection with explicit sibling deny paths. */ export const trainingOrganizationRuntimeConfig = (declaration: { agentId: string; model: DaimonGrokBrokerModel; reasoningEffort: DaimonGrokBrokerReasoningEffort; -}): Record => ({ +}, slot = 0): Record => ({ version: "noopolis.daimon.organization-runtime.v1", - host: { bindHost: "127.0.0.1", port: 19_700, controlTokenEnv: "SPAWNFILE_DAIMON_CONTROL_TOKEN" }, + host: { bindHost: "127.0.0.1", port: trainingSlotPaths(slot).ports.workerControl, controlTokenEnv: "SPAWNFILE_DAIMON_CONTROL_TOKEN" }, agents: [{ id: declaration.agentId, name: declaration.agentId, instructions: "training subject", - workspacePath: TRAINING_SLOT_WORKSPACE, - runtimeHomePath: TRAINING_SLOT_RUNTIME_HOME, + workspacePath: trainingSlotPaths(slot).workspace, + runtimeHomePath: trainingSlotPaths(slot).runtimeHome, engine: { kind: "grok", model: declaration.model, reasoningEffort: declaration.reasoningEffort } }] }); @@ -81,30 +81,31 @@ export const resolveTrainingGrokProjection = async ( daimon: DaimonProjectionModule ): Promise => { const projection = daimon.resolveOrganizationGrokBrokerProjection( - trainingOrganizationRuntimeConfig(declaration), declaration.agentId, { + trainingOrganizationRuntimeConfig(declaration, registration.slot), declaration.agentId, { slot: registration.slot, - workerUid: registration.uid, - workerHomePath: TRAINING_WORKER_HOME, workerConfigVariant: "clocked", + workerUid: registration.uid, + workerHomePath: registration.home, architecture: declaration.architecture, usageLedgerPath: registration.usageLedgerPath, limits: declaration.limits, // The mask that protects the wake-acceptance store is the slot state root it lives in: the store's // own parent is `2000:2000 0700`, so bubblewrap could not materialize a deny target inside it as // the worker uid. Every party that recomputes this projection must pass the same value. - acceptanceStorePath: TRAINING_SLOT_STATE_ROOT, + acceptanceStorePath: trainingSlotPaths(registration.slot).state, denyPaths: registration.denyPaths, seccompProfileSha256: declaration.seccompProfileSha256, profileSha256: registration.profileSha256 }) as unknown as Record; const clock = DAIMON_GROK_ENGINE_BROKER.worker.taskClock; - const projectedClock = projection.taskClock as Record | undefined; - if (registration.workerConfigVariant !== "clocked" || projection.version !== DAIMON_GROK_ENGINE_BROKER.projectionVersion - || projection.workerConfigVariant !== "clocked" || projection.workerConfigSha256 !== registration.configSha256 - || projection.profileSha256 !== registration.profileSha256 || !projectedClock - || Object.keys(projectedClock).length !== Object.keys(clock).length - || Object.entries(clock).some(([key, value]) => projectedClock[key] !== value)) { - throw new SpawnfileError("runtime_error", "Daimon's Grok broker projection differs from the provisioned clocked worker config"); + 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[])]; if (denied.length !== registration.denyPaths.length || denied.some((entry, index) => entry !== registration.denyPaths[index])) { diff --git a/src/compiler/training/broker/provisioning.test.ts b/src/compiler/training/broker/provisioning.test.ts index 238492ee..c60652f7 100644 --- a/src/compiler/training/broker/provisioning.test.ts +++ b/src/compiler/training/broker/provisioning.test.ts @@ -10,7 +10,7 @@ import { trainingContainerConfigSchema } from "../container/contract.js"; import { renderTrainingBrokerProvisioning, renderTrainingSealedInputsAssertions } from "./provisioning.js"; import { resolveTrainingGrokRegistration } from "./registration.js"; import { pinnedProfileAllowsNamespaceRoutes, trainingNamespaceDenialMechanism } from "./seccompRoutes.js"; -import { TRAINING_SEALED_INPUTS_IDENTITY, TRAINING_SEALED_INPUTS_ROOT, TRAINING_WORKER_UID } from "./paths.js"; +import { TRAINING_SEALED_INPUTS_IDENTITY, TRAINING_SEALED_INPUTS_ROOT, trainingSlotPaths, trainingEvaluatorPaths, trainingWipeTargets, trainingClearTargets } from "./paths.js"; const run = promisify(execFile); const registration = () => resolveTrainingGrokRegistration({ agentId: "agent:author", model: "grok-4.6", reasoningEffort: "low" }); @@ -47,6 +47,7 @@ const runVerdict = async (verdict: string, namespaceAvailable = true): Promise<{ ({ code: error.code ?? 1, stdout: error.stdout ?? "", stderr: error.stderr ?? "" })); }; + describe("the sealed inputs seal", () => { it("asserts the image-baked identity of the datasets' ancestor rather than trying to set it", () => { const script = renderTrainingSealedInputsAssertions().join("\n"); @@ -189,3 +190,5 @@ describe("where the seal probes are allowed to work", () => { expect(script).toContain("unshare --user --map-root-user --mount true"); }); }); + +const { workerUid: TRAINING_WORKER_UID } = trainingSlotPaths(0); diff --git a/src/compiler/training/broker/provisioning.ts b/src/compiler/training/broker/provisioning.ts index f7a3707f..2b3ab3e8 100644 --- a/src/compiler/training/broker/provisioning.ts +++ b/src/compiler/training/broker/provisioning.ts @@ -1,12 +1,14 @@ 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"; import { DAIMON_WAKE_FUSE_DIRECTORY } from "../../../runtime/daimon/config.js"; import type { DaimonGrokRegistration } from "../../containerDaimonGrokWorkerRender.js"; -import { renderDaimonBrokerProvisioningProgram } from "../../containerDaimonBrokerRender.js"; -import { renderDaimonGrokHostPreflight } from "../../containerDaimonGrokWorkerProvisioning.js"; +import { renderDaimonBrokerProvisioningProgram, renderDaimonWorkspaceResourceSecurity } from "../../containerDaimonBrokerRender.js"; +import { renderDaimonGrokHostPreflight, renderDaimonGrokWorkerProvisioning } from "../../containerDaimonGrokWorkerProvisioning.js"; import { + TRAINING_BROKER_SOCKET_PATHS, trainingSlotPaths, trainingSlotDescriptors, TRAINING_TURN_STORE_ROOT, TRAINING_RUN_REQUEST_STORE, DAIMON_BROKER_UID, DAIMON_ORGANIZATION_UID, TRAINING_GRANT_HOME_ROOT, @@ -14,23 +16,13 @@ import { TRAINING_INFERENCE_LEDGER, TRAINING_OPTIONAL_DENY_DIRECTORIES, TRAINING_PAIDEIA_ROOT, - TRAINING_SLOT_ACCEPTANCE_STORE, - TRAINING_SEALED_INPUTS_ATTESTATION, TRAINING_SEALED_INPUTS_IDENTITY, TRAINING_SEALED_INPUTS_ROOT, - TRAINING_SLOT_ROOT, - TRAINING_SLOT_STATE_ROOT, - TRAINING_SLOT_TURN_STORE, - TRAINING_SLOT_USAGE_DIRECTORY, - TRAINING_SLOT_USAGE_LEDGER, TRAINING_SUPERVISOR_DIRECTORY, TRAINING_BROKER_TMPDIR, - TRAINING_SLOT_WORKSPACE, TRAINING_WORKER_ROOT, - TRAINING_WORKER_UID } from "./paths.js"; import { trainingNamespaceDenialMechanism } from "./seccompRoutes.js"; -import { renderTrainingTaskClockAssertions, renderTrainingTaskClockReset } from "./taskClock.js"; const quote = (value: string): string => `'${value.replace(/'/g, `'"'"'`)}'`; @@ -56,21 +48,22 @@ const PROVISION_DIRECTORY_HELPER = [ * `2100:2000 2750` uid 2000 — Paideia, DSPy and every judge — cannot read a * single subject usage row or inference row it paid for. */ -export const trainingSlotDirectories = (): readonly { path: string; mode: string; uid: number; gid: number }[] => [ +export const trainingSlotDirectories = (slot = 0): readonly { path: string; mode: string; uid: number; gid: number }[] => [ // `/run/training` itself is deliberately absent: it is a mount-point parent on the read-only // image root, and every writable child below it is its own tmpfs or bind. - { path: TRAINING_SLOT_ROOT, mode: "0755", uid: 0, gid: 0 }, + { path: trainingSlotPaths(slot).root, mode: "0711", uid: 0, gid: 0 }, { path: TRAINING_PAIDEIA_ROOT, mode: "0750", uid: DAIMON_ORGANIZATION_UID, gid: DAIMON_ORGANIZATION_UID }, { path: "/home/training", mode: "0700", uid: DAIMON_ORGANIZATION_UID, gid: DAIMON_ORGANIZATION_UID }, { path: "/work", mode: "0700", uid: DAIMON_ORGANIZATION_UID, gid: DAIMON_ORGANIZATION_UID }, - { path: TRAINING_SLOT_WORKSPACE, mode: "0750", uid: DAIMON_ORGANIZATION_UID, gid: TRAINING_WORKER_UID }, + { path: trainingSlotPaths(slot).workspace, mode: "0750", uid: DAIMON_ORGANIZATION_UID, gid: trainingSlotPaths(slot).workerUid }, // The agent runtime home is deliberately absent: the shared broker provisioning creates it root-owned, // fills its setgid `tool-output/`, and only then narrows it to `2000: 0710`. Handing it over here // would leave root — which holds no `CAP_DAC_OVERRIDE` — unable to create the spill directory inside it. - { path: TRAINING_SLOT_STATE_ROOT, mode: "0700", uid: DAIMON_ORGANIZATION_UID, gid: DAIMON_ORGANIZATION_UID }, - { path: TRAINING_SLOT_ACCEPTANCE_STORE, mode: "0700", uid: DAIMON_ORGANIZATION_UID, gid: DAIMON_ORGANIZATION_UID }, - { path: TRAINING_SLOT_TURN_STORE, mode: "0700", uid: DAIMON_BROKER_UID, gid: DAIMON_BROKER_UID }, - { path: TRAINING_SLOT_USAGE_DIRECTORY, mode: "2750", uid: DAIMON_BROKER_UID, gid: DAIMON_ORGANIZATION_UID }, + { path: trainingSlotPaths(slot).state, mode: "0700", uid: DAIMON_ORGANIZATION_UID, gid: DAIMON_ORGANIZATION_UID }, + { path: trainingSlotPaths(slot).acceptanceStore, mode: "0700", uid: DAIMON_ORGANIZATION_UID, gid: DAIMON_ORGANIZATION_UID }, + { path: TRAINING_TURN_STORE_ROOT, mode: "0700", uid: DAIMON_BROKER_UID, gid: DAIMON_BROKER_UID }, + { path: TRAINING_RUN_REQUEST_STORE, mode: "0700", uid: DAIMON_BROKER_UID, gid: DAIMON_BROKER_UID }, + { path: trainingSlotPaths(slot).usageDirectory, mode: "2750", uid: DAIMON_BROKER_UID, gid: DAIMON_ORGANIZATION_UID }, { path: TRAINING_INFERENCE_DIRECTORY, mode: "2750", uid: DAIMON_BROKER_UID, gid: DAIMON_ORGANIZATION_UID }, { path: TRAINING_GRANT_HOME_ROOT, mode: "0700", uid: DAIMON_ORGANIZATION_UID, gid: DAIMON_ORGANIZATION_UID }, // Traverse only: the supervisor socket inside it is the uid gate, and a directory nobody but root may @@ -95,56 +88,73 @@ export const trainingSlotDirectories = (): readonly { path: string; mode: string * and the worker uid instead, and this refuses to provision a slot in an image * that did not. */ -export const renderTrainingIdentities = (): string[] => [ - `for fixed_uid in ${DAIMON_ORGANIZATION_UID} ${DAIMON_BROKER_UID} ${TRAINING_WORKER_UID}; do`, +export const renderTrainingIdentities = (slots = 4): string[] => [ + `for fixed_uid in ${DAIMON_ORGANIZATION_UID} ${DAIMON_BROKER_UID} ${trainingSlotDescriptors(slots).map(p => p.workerUid).join(" ")}; do`, ' getent passwd "$fixed_uid" >/dev/null && getent group "$fixed_uid" >/dev/null || { echo "the training image must bake uid/gid $fixed_uid; the container root is read-only" >&2; exit 1; }', "done" ]; -/** - * Root provisioning for the training container's one broker slot, as bash - * lines run by the image's own root entrypoint and replayed verbatim by the - * slot supervisor on every recycle. - * - * Everything below the slot skeleton is Daimon's audited production - * provisioning: the same credential bootstrap and journal recovery, the same - * `registrations.bin`, the same attested worker `GROK_HOME` layout, the same - * P1b private worker temp, closed shared temp and setgid spill directory, and - * the same `service.json` — here v2 with the per-slot usage ledger, the - * per-slot tmpfs turn store and the evaluator `inferenceLedgerPath`. - */ -export const renderTrainingBrokerProvisioning = (registration: DaimonGrokRegistration): string[] => [ +/** Slot-local skeleton, shared by initial provisioning and recycle. */ +const slotDirectories = (slot: number) => trainingSlotDirectories(slot).filter(entry => + entry.path === trainingSlotPaths(slot).root || entry.path.startsWith(`${trainingSlotPaths(slot).root}/`)); +const renderDirectories = (entries: ReturnType, ledgers: string[]): string[] => [ ...PROVISION_DIRECTORY_HELPER, - // Two passes, because root here holds neither `CAP_DAC_OVERRIDE` nor `CAP_FOWNER`: create every - // directory while they are all still root-owned and traversable, then set ownership and mode from the - // deepest path up, so tightening a parent to `0700` never strands a child that still has to be created. - ...trainingSlotDirectories().map((entry) => `mkdir -p ${quote(entry.path)}`), - // Every deny entry needs an inode to mask; a bind the host did not supply is created root-owned and unreadable. - ...TRAINING_OPTIONAL_DENY_DIRECTORIES.map((target) => `if [ ! -e ${quote(target)} ]; then mkdir -p ${quote(target)}; provision_dir ${quote(target)} 0700 0 0; fi`), - // Both ledgers exist before their directories are handed to the broker: the broker appends rows `0640` in - // its own group, and the setgid directory below is what lets uid 2000 read a row it paid for. - ...[TRAINING_SLOT_USAGE_LEDGER, TRAINING_INFERENCE_LEDGER].map((ledger) => - `if [ ! -e ${quote(ledger)} ]; then : > ${quote(ledger)}; fi; chown 0:0 ${quote(ledger)}; chmod 0640 ${quote(ledger)}; chown ${DAIMON_BROKER_UID}:${DAIMON_ORGANIZATION_UID} ${quote(ledger)}`), - ...[...trainingSlotDirectories()].sort((left, right) => right.path.split("/").length - left.path.split("/").length) - .map((entry) => `provision_dir ${quote(entry.path)} ${entry.mode} ${entry.uid} ${entry.gid}`), - ...renderDaimonGrokHostPreflight(), - ...renderTrainingTaskClockReset(), - ...renderDaimonBrokerProvisioningProgram([registration], [], { - turnStore: TRAINING_SLOT_TURN_STORE, - inferenceLedgerPath: TRAINING_INFERENCE_LEDGER - }, "clear", TRAINING_OPTIONAL_DENY_DIRECTORIES, TRAINING_BROKER_TMPDIR), - ...renderTrainingTaskClockAssertions(registration), - // The shared program leaves the broker's `/etc` root `0555 root:root`, which every uid can list. Training - // denies that directory to its worker, and a canary can only observe a denial the kernel actually enforces. - `chmod 0550 /etc/daimon-engine-broker; chown 0:${DAIMON_BROKER_UID} /etc/daimon-engine-broker`, - `test "$(stat -c '%u:%g %a' ${quote(TRAINING_SLOT_USAGE_DIRECTORY)})" = "${DAIMON_BROKER_UID}:${DAIMON_ORGANIZATION_UID} 2750"`, + ...entries.map(entry => `mkdir -p ${quote(entry.path)}`), + ...ledgers.map(ledger => `if [ ! -e ${quote(ledger)} ]; then : > ${quote(ledger)}; fi; chown 0:0 ${quote(ledger)}; chmod 0640 ${quote(ledger)}; chown ${DAIMON_BROKER_UID}:${DAIMON_ORGANIZATION_UID} ${quote(ledger)}`), + ...[...entries].sort((a, b) => b.path.split("/").length - a.path.split("/").length) + .map(entry => `provision_dir ${quote(entry.path)} ${entry.mode} ${entry.uid} ${entry.gid}`) +]; + +const renderSlotAssertions = (r: DaimonGrokRegistration): string[] => [ + `test "$(stat -c '%u:%g %a' ${quote(trainingSlotPaths(r.slot).usageDirectory)})" = "${DAIMON_BROKER_UID}:${DAIMON_ORGANIZATION_UID} 2750"`, `test "$(stat -c '%u:%g %a' ${quote(TRAINING_INFERENCE_DIRECTORY)})" = "${DAIMON_BROKER_UID}:${DAIMON_ORGANIZATION_UID} 2750"`, `test "$(stat -c '%u:%g %a' ${quote(TRAINING_GRANT_HOME_ROOT)})" = "${DAIMON_ORGANIZATION_UID}:${DAIMON_ORGANIZATION_UID} 700"`, - // The judge grant home root is the evaluator's alone: no worker uid may even traverse it. - `setpriv --clear-groups --reuid ${TRAINING_WORKER_UID} --regid ${TRAINING_WORKER_UID} --inh-caps=-all --ambient-caps=-all --bounding-set=-all -- bash -ceu '! test -r ${quote(TRAINING_GRANT_HOME_ROOT)}'`, - `setpriv --clear-groups --reuid ${DAIMON_ORGANIZATION_UID} --regid ${DAIMON_ORGANIZATION_UID} --inh-caps=-all --ambient-caps=-all --bounding-set=-all -- bash -ceu 'test -r ${quote(TRAINING_SLOT_USAGE_LEDGER)} && test -r ${quote(TRAINING_INFERENCE_LEDGER)}'`, - // The sealed train and test datasets: the one boundary here that must hold against the subject's own - // namespace, not only against an honest tool. Last, so it runs with every mode already final. + `setpriv --clear-groups --reuid ${r.uid} --regid ${r.uid} --inh-caps=-all --ambient-caps=-all --bounding-set=-all -- bash -ceu '! test -r ${quote(TRAINING_GRANT_HOME_ROOT)}'`, + `setpriv --clear-groups --reuid ${DAIMON_ORGANIZATION_UID} --regid ${DAIMON_ORGANIZATION_UID} --inh-caps=-all --ambient-caps=-all --bounding-set=-all -- bash -ceu 'test -r ${quote(r.usageLedgerPath)} && test -r ${quote(TRAINING_INFERENCE_LEDGER)}'` +]; + +/** Full container provisioning runs exactly once, before any broker process starts. */ +export const renderTrainingBrokerProvisioning = (input: DaimonGrokRegistration | readonly DaimonGrokRegistration[], maxRunRequests = 1000): string[] => { + if (!Number.isSafeInteger(maxRunRequests) || maxRunRequests < 1) throw new Error("Invalid run request cap"); + const registrations = Array.isArray(input) ? input : [input as 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, [ + `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); + return [ + `mkdir -p ${quote(p.clockDirectory)}; chown 0:0 ${quote(p.clockDirectory)}; chmod 0755 ${quote(p.clockDirectory)}`, + `printf '+0\\n' > ${quote(p.clockFile)}; chmod 0644 ${quote(p.clockFile)}; chown ${DAIMON_BROKER_UID}:${DAIMON_BROKER_UID} ${quote(p.clockFile)}`, + `chown ${DAIMON_BROKER_UID}:${DAIMON_BROKER_UID} ${quote(p.clockDirectory)}` + ]; + }) + ]), + `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)]) + ]; +}; + +/** Reuse the audited worker renderer; never touch service config, credentials, shared temps or siblings. */ +export const renderTrainingSlotProvisioning = (registration: DaimonGrokRegistration): string[] => [ + ...renderDirectories(slotDirectories(registration.slot), [registration.usageLedgerPath]), + // Keep worker-home and private-tmp identities stable: sibling turns attest them concurrently. + `setpriv --clear-groups --reuid=${registration.uid} --regid=${registration.uid} --inh-caps=-all --ambient-caps=-all --bounding-set=-all -- mkdir -p ${quote(registration.grokHome)}`, + `chown 0:0 ${quote(registration.grokHome)}; chmod 0700 ${quote(registration.grokHome)}`, + + "node <<'SPAWNFILE_TRAINING_SLOT_PROVISION'", + "const crypto = require('node:crypto'); const fs = require('node:fs');", + ...renderDaimonWorkspaceResourceSecurity([]), + ...renderDaimonGrokWorkerProvisioning([registration], {}, [], "slot"), + "SPAWNFILE_TRAINING_SLOT_PROVISION", + ...renderSlotAssertions(registration), ...renderTrainingSealedInputsAssertions(registration.uid, registration.privateTmp) ]; @@ -193,14 +203,14 @@ export const renderTrainingBrokerProvisioning = (registration: DaimonGrokRegistr * the seal. The private tmp is also the workspace the subject itself has. */ export const renderTrainingSealedInputsAssertions = ( - workerUid = TRAINING_WORKER_UID, - privateTmp = path.posix.join(TRAINING_WORKER_ROOT, String(TRAINING_WORKER_UID), "tmp"), + workerUid = trainingSlotPaths(0).workerUid, + privateTmp = trainingSlotPaths(0).privateTmp, mechanism = trainingNamespaceDenialMechanism() ): string[] => [ // Diagnostics go to a file rather than into the verdict: a route's stderr is util-linux prose that // would turn every real verdict into "unknown", but a no-verdict refusal with no diagnostic is what // made the first live failure unreadable, so the unknown branch prints it back. - `seal_diagnostic=${quote(`${TRAINING_SLOT_ROOT}/seal-probe.err`)}`, + `seal_diagnostic=${quote(`${trainingSlotPaths(workerUid - trainingSlotPaths(0).workerUid).root}/seal-probe.err`)}`, `seal_worker() { setpriv --clear-groups --reuid ${workerUid} --regid ${workerUid} --inh-caps=-all --ambient-caps=-all --bounding-set=-all -- /bin/sh -c "$1" 2>"$seal_diagnostic"; }`, "seal_rows=''", 'seal_record() { if [ -n "$seal_rows" ]; then seal_rows="$seal_rows,"; fi; seal_rows="$seal_rows{\\"route\\":\\"$1\\",\\"verdict\\":\\"$2\\"}"; }', @@ -236,7 +246,7 @@ export const renderTrainingSealedInputsAssertions = ( // cross-repo `noopolis.daimon.grok-slot-preflight.v2` canary shape is deliberately untouched — its // schema lives in Daimon and no consumer here can validate an added member — so the per-route // mechanism is recorded here rather than implied by a bare `result: "denied"` there. - `seal_attestation=${quote(TRAINING_SEALED_INPUTS_ATTESTATION)}`, + `seal_attestation=${quote(trainingSlotPaths(workerUid - trainingSlotPaths(0).workerUid).sealedInputsAttestation)}`, `printf '{"version":"spawnfile.training-sealed-inputs.v1","root":"%s","worker_uid":%s,"identity":"%s","pinned_seccomp_profile_sha256":"%s","namespace_routes":"%s","routes":[%s]}\\n' ` + `${quote(TRAINING_SEALED_INPUTS_ROOT)} ${workerUid} ` + `"$(stat -c '%u:%g %a' ${quote(TRAINING_SEALED_INPUTS_ROOT)})" ${quote(DAIMON_GROK_SECCOMP_PROFILE_SHA256)} ` diff --git a/src/compiler/training/broker/receipt.test.ts b/src/compiler/training/broker/receipt.test.ts index df818a6f..90cd4b30 100644 --- a/src/compiler/training/broker/receipt.test.ts +++ b/src/compiler/training/broker/receipt.test.ts @@ -136,7 +136,7 @@ describe("training slot canaries", () => { }); it("resolves the longest matching mount point", () => { - expect(resolveBackingFilesystem("/run/training/slot/usage/usage.jsonl", mountinfoFor("virtiofs"))).toBe("tmpfs"); + expect(resolveBackingFilesystem("/run/training/slots/0/usage/usage.jsonl", mountinfoFor("virtiofs"))).toBe("tmpfs"); expect(resolveBackingFilesystem(`${TRAINING_RUN_ROOT}/runs`, mountinfoFor("virtiofs"))).toBe("virtiofs"); // The sealed root is the datasets' parent on the image root, never a mount of its own. expect(resolveBackingFilesystem(TRAINING_SEALED_INPUTS_ROOT, mountinfoFor("virtiofs"))).toBe("overlay"); diff --git a/src/compiler/training/broker/registration.test.ts b/src/compiler/training/broker/registration.test.ts index 0dd3eb01..d7beaa3c 100644 --- a/src/compiler/training/broker/registration.test.ts +++ b/src/compiler/training/broker/registration.test.ts @@ -1,35 +1,16 @@ import { describe, expect, it } from "vitest"; -import { DAIMON_GROK_ENGINE_BROKER } from "../../../runtime/daimon/contractManifest.js"; -import { DAIMON_GROK_CLOCKED_WORKER_CONFIG_BYTES } from "../../../runtime/daimon/grokWorkerConfigBytes.js"; -import { renderDaimonGrokWorkerProvisioning } from "../../containerDaimonGrokWorkerProvisioning.js"; import { renderDaimonGrokServiceConfig } from "../../containerDaimonGrokWorkerRender.js"; import { trainingBrokerTmpfsTargets } from "../container/security.js"; import { brokerProcessPlan } from "./processes.js"; import { renderTrainingBrokerProvisioning, trainingSlotDirectories } from "./provisioning.js"; import { resolveTrainingGrokDenyPaths, resolveTrainingGrokRegistration } from "./registration.js"; -import { - TRAINING_ADDED_DENY_PATHS, - TRAINING_BROKER_TMPDIR, - TRAINING_BROKER_SOCKET_PATHS, - TRAINING_SLOT_RUNTIME_HOME, - TRAINING_WORKER_HOME, - TRAINING_SLOT_WORKSPACE, - TRAINING_CALLER_PROTECTED_PATHS, - TRAINING_EVALUATOR_ROOTS, - TRAINING_GRANT_HOME_ROOT, - TRAINING_INFERENCE_DIRECTORY, - TRAINING_INFERENCE_LEDGER, - TRAINING_REALM_MOUNT, - TRAINING_SLOT_ACCEPTANCE_STORE, - TRAINING_SLOT_STATE_ROOT, - TRAINING_SLOT_TURN_STORE, - TRAINING_SLOT_USAGE_DIRECTORY, - TRAINING_SLOT_USAGE_LEDGER -} from "./paths.js"; +import { TRAINING_BROKER_TMPDIR, TRAINING_CALLER_PROTECTED_PATHS, TRAINING_GRANT_HOME_ROOT, TRAINING_INFERENCE_DIRECTORY, TRAINING_INFERENCE_LEDGER, TRAINING_REALM_MOUNT, trainingSlotPaths, trainingEvaluatorPaths, trainingWipeTargets, trainingClearTargets } from "./paths.js"; const slot = () => resolveTrainingGrokRegistration({ agentId: "agent:author", model: "grok-4.6", reasoningEffort: "low" }); +const TRAINING_ADDED_DENY_PATHS = trainingEvaluatorPaths(0, 1).map(p => p.path).sort(); +const TRAINING_EVALUATOR_ROOTS = trainingEvaluatorPaths(0, 1).filter((p, i, rows) => rows.findIndex(q => q.role === p.role) === i); describe("training Grok slot registration", () => { it("denies every evaluator root, the caller's whole /run/paideia, the control root and the judge grant home", () => { const denied = slot().denyPaths; @@ -39,8 +20,8 @@ describe("training Grok slot registration", () => { expect(denied.some((entry) => covered === entry || covered.startsWith(`${entry}/`)), covered).toBe(true); } for (const entry of [...TRAINING_EVALUATOR_ROOTS.map((role) => role.path), - ...TRAINING_BROKER_SOCKET_PATHS, "/etc/daimon-engine-broker", TRAINING_GRANT_HOME_ROOT, TRAINING_INFERENCE_DIRECTORY, - TRAINING_SLOT_STATE_ROOT, TRAINING_SLOT_TURN_STORE, TRAINING_REALM_MOUNT]) { + "/run/daimon-engine-broker/control.sock", "/etc/daimon-engine-broker", TRAINING_GRANT_HOME_ROOT, TRAINING_INFERENCE_DIRECTORY, + TRAINING_SLOT_STATE_ROOT, "/run/training/turns", TRAINING_REALM_MOUNT]) { expect(denied, entry).toContain(entry); } expect(denied).toEqual([...denied].sort()); @@ -71,9 +52,16 @@ describe("training Grok slot registration", () => { expect(() => resolveTrainingGrokDenyPaths([...TRAINING_ADDED_DENY_PATHS, "/var/tmp"])).toThrow(/base profile grant/u); }); + 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", + "/usr/lib/x86_64-linux-gnu/faketime", "/usr/lib/aarch64-linux-gnu/faketime"]) { + expect(() => resolveTrainingGrokDenyPaths([denied])).toThrow(/task clock/); + } + }); + it("refuses nested masks and a mask over the subject's own workspace", () => { expect(() => resolveTrainingGrokDenyPaths([...TRAINING_ADDED_DENY_PATHS, "/run/training/output/runs"])).toThrow(/masks cannot nest/u); - expect(() => resolveTrainingGrokDenyPaths([...TRAINING_ADDED_DENY_PATHS, "/run/training/slot/runtime-home"])).toThrow(/own workspace, home, or runtime home/u); + expect(() => resolveTrainingGrokDenyPaths([...TRAINING_ADDED_DENY_PATHS, "/run/training/slots/0/runtime-home"])).toThrow(/own workspace, home, or runtime home/u); }); it("points the registration at the per-slot ledger, never the container ledger", () => { @@ -81,28 +69,6 @@ describe("training Grok slot registration", () => { expect(slot().usageLedgerPath.startsWith("/var/lib/spawnfile/daimon/usage")).toBe(false); }); - it("selects the vendored clocked bytes and keeps the directory visible through strict's read-only /run mount", () => { - const registration = slot(), clock = DAIMON_GROK_ENGINE_BROKER.worker.taskClock; - expect(registration.workerConfigVariant).toBe("clocked"); - expect(registration.config).toBe(DAIMON_GROK_CLOCKED_WORKER_CONFIG_BYTES["grok-4.6"].low); - expect(registration.configSha256).toBe(DAIMON_GROK_ENGINE_BROKER.worker.clockedConfigSha256["grok-4.6"].low); - expect(registration.profile).toContain('extends = "strict"'); - expect(registration.profile).not.toMatch(/write|read_write/u); - for (const file of [clock.timestampFilePath, clock.directoryPath, clock.preloadPath, - "/usr/lib/x86_64-linux-gnu/faketime/libfaketime.so.1", "/usr/lib/aarch64-linux-gnu/faketime/libfaketime.so.1"]) { - expect(registration.denyPaths.some((denied) => file === denied || file.startsWith(`${denied}/`))).toBe(false); - expect(() => resolveTrainingGrokDenyPaths([...TRAINING_ADDED_DENY_PATHS, file])).toThrow(/task clock|base profile grant/u); - } - expect(() => resolveTrainingGrokDenyPaths([...TRAINING_ADDED_DENY_PATHS, "/run/daimon-engine-broker"])).toThrow(/task clock/u); - }); - - it("refuses multiple registrations when any slot is clocked", () => { - for (const variant of ["clocked", "unclocked"] as const) { - expect(() => renderDaimonGrokWorkerProvisioning([slot(), { ...slot(), agentId: "agent:peer", workerConfigVariant: variant }])) - .toThrow(/exactly one registration/u); - } - }); - it("refuses an undeclared model or reasoning effort", () => { expect(() => resolveTrainingGrokRegistration({ agentId: "agent:author", model: "grok-9" as never, reasoningEffort: "low" })).toThrow(/declared broker model/u); }); @@ -182,3 +148,5 @@ describe("training wipe targets and the broker temp", () => { expect(script).toContain("spawnfile_holds_mount"); }); }); + +const { workspace: TRAINING_SLOT_WORKSPACE, runtimeHome: TRAINING_SLOT_RUNTIME_HOME, state: TRAINING_SLOT_STATE_ROOT, acceptanceStore: TRAINING_SLOT_ACCEPTANCE_STORE, turns: TRAINING_SLOT_TURN_STORE, usageDirectory: TRAINING_SLOT_USAGE_DIRECTORY, usageLedger: TRAINING_SLOT_USAGE_LEDGER, workerHome: TRAINING_WORKER_HOME } = trainingSlotPaths(0); diff --git a/src/compiler/training/broker/registration.ts b/src/compiler/training/broker/registration.ts index 09128ee9..57267f23 100644 --- a/src/compiler/training/broker/registration.ts +++ b/src/compiler/training/broker/registration.ts @@ -12,26 +12,14 @@ import { DAIMON_GROK_WORKER_HOME_DIRECTORY, daimonGrokWorkerSandboxProfileSha256, renderDaimonGrokWorkerSandboxProfile, - resolveDaimonGrokWorkerConfig + resolveDaimonGrokClockedWorkerConfig } from "../../../runtime/daimon/grokWorkerContract.js"; import { DAIMON_GROK_BASE_PROFILE_GRANTS, assertCanonicalRegisteredPath, type DaimonGrokRegistration } from "../../containerDaimonGrokWorkerRender.js"; -import { - TRAINING_ADDED_DENY_PATHS, - TRAINING_BOOTSTRAP_MOUNT, - TRAINING_DEFERRED_DENY_PATHS, - TRAINING_REALM_MOUNT, - TRAINING_SLOT_INDEX, - TRAINING_SLOT_RUNTIME_HOME, - TRAINING_SLOT_STATE_ROOT, - TRAINING_SLOT_USAGE_LEDGER, - TRAINING_SLOT_WORKSPACE, - TRAINING_WORKER_HOME, - TRAINING_WORKER_UID -} from "./paths.js"; +import { trainingSlotPaths, trainingEvaluatorPaths, TRAINING_BOOTSTRAP_MOUNT, TRAINING_REALM_MOUNT, TRAINING_DEFERRED_DENY_PATHS } from "./paths.js"; const fail = (message: string): never => { throw new SpawnfileError("compile_error", message); @@ -52,29 +40,31 @@ const within = (candidate: string, root: string): boolean => candidate === root * the profile outright, and masks cannot nest, so an entry covered by another * entry is a provisioning bug rather than something to silently drop. */ -export const resolveTrainingGrokDenyPaths = (added: readonly string[] = TRAINING_ADDED_DENY_PATHS): string[] => { - // The wake-acceptance store is masked through `TRAINING_SLOT_STATE_ROOT`, never directly: Grok 1.0.34 +export const resolveTrainingGrokDenyPaths = (added: readonly string[] | undefined = undefined, slot = 0, slots = 1): string[] => { + const p = trainingSlotPaths(slot); + const required = trainingEvaluatorPaths(slot, slots).map(entry => entry.path); + // The wake-acceptance store is masked through `p.state`, never directly: Grok 1.0.34 // materializes every deny target inside bubblewrap AS THE WORKER UID, and the store's parent is // `2000:2000 0700`, so the store itself is unplaceable and would make Grok refuse the whole profile // (`.runtime/grok-deny-placement/EVIDENCE.md`). The state root covers it and nothing else lives there. - const daimonOwn = [TRAINING_BOOTSTRAP_MOUNT, TRAINING_REALM_MOUNT, TRAINING_SLOT_STATE_ROOT]; - const grokHome = path.posix.join(TRAINING_WORKER_HOME, DAIMON_GROK_WORKER_HOME_DIRECTORY); + const daimonOwn = [TRAINING_BOOTSTRAP_MOUNT, TRAINING_REALM_MOUNT, p.state]; + const grokHome = path.posix.join(p.workerHome, DAIMON_GROK_WORKER_HOME_DIRECTORY); const grants = [ - ...DAIMON_GROK_BASE_PROFILE_GRANTS, TRAINING_SLOT_WORKSPACE, grokHome, - path.posix.join(grokHome, "sessions"), path.posix.join(TRAINING_WORKER_HOME, "tmp") + ...DAIMON_GROK_BASE_PROFILE_GRANTS, p.workspace, grokHome, + path.posix.join(grokHome, "sessions"), path.posix.join(p.workerHome, "tmp") ]; - const clock = DAIMON_GROK_ENGINE_BROKER.worker.taskClock; - const clockPaths = [clock.directoryPath, clock.preloadPath, - "/usr/lib/x86_64-linux-gnu/faketime/libfaketime.so.1", "/usr/lib/aarch64-linux-gnu/faketime/libfaketime.so.1"]; - const candidates = [...new Set([...daimonOwn, ...added])].sort(); + const candidates = [...new Set([...daimonOwn, ...required, ...(added ?? [])])].sort(); for (const entry of candidates) { assertCanonicalRegisteredPath("deny path", entry); const grant = grants.find((candidate) => within(candidate, entry)); if (grant) fail(`Training Grok deny path ${entry} equals or contains the base profile grant ${grant}; Grok refuses such a profile`); - if (within(entry, clock.directoryPath) || clockPaths.some((required) => within(required, entry))) fail(`Training Grok deny path ${entry} hides the task clock`); - if (within(TRAINING_SLOT_WORKSPACE, entry) || within(TRAINING_WORKER_HOME, entry) || within(TRAINING_SLOT_RUNTIME_HOME, entry)) { + 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, + "/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)); if (ancestor) fail(`Training Grok deny path ${ancestor} would cover ${entry}; masks cannot nest`); } @@ -82,6 +72,8 @@ export const resolveTrainingGrokDenyPaths = (added: readonly string[] = TRAINING }; export interface TrainingGrokSlotInput { + slot?: number; + slots?: number; agentId: string; model: DaimonGrokBrokerModel; reasoningEffort: DaimonGrokBrokerReasoningEffort; @@ -103,27 +95,28 @@ export interface TrainingGrokSlotInput { * not exist here. */ export const resolveTrainingGrokRegistration = (input: TrainingGrokSlotInput): DaimonGrokRegistration => { + const p = trainingSlotPaths(input.slot ?? 0); if (!input.agentId.trim()) fail("Training Grok slot requires an agent id"); if (!(DAIMON_GROK_BROKER_MODELS as readonly string[]).includes(input.model) || !(DAIMON_GROK_BROKER_REASONING_EFFORTS as readonly string[]).includes(input.reasoningEffort)) { fail(`Training Grok slot has no declared broker model and reasoning effort: ${input.agentId}`); } - const home = assertCanonicalRegisteredPath("home", TRAINING_WORKER_HOME); + const home = assertCanonicalRegisteredPath("home", p.workerHome); const grokHome = path.posix.join(home, DAIMON_GROK_WORKER_HOME_DIRECTORY); - const config = resolveDaimonGrokWorkerConfig(input.model, input.reasoningEffort, "clocked"); - const denyPaths = resolveTrainingGrokDenyPaths(input.denyPaths); + const config = resolveDaimonGrokClockedWorkerConfig(input.model, input.reasoningEffort, p.slot); + const denyPaths = resolveTrainingGrokDenyPaths(input.denyPaths, p.slot, input.slots ?? 1); const profile = renderDaimonGrokWorkerSandboxProfile(denyPaths); const eventsPath = path.posix.join(grokHome, DAIMON_GROK_ENGINE_BROKER.worker.home.sandboxEvents.relativePath); const profilePath = path.posix.join(grokHome, "sandbox.toml"); for (const [label, value] of [["GROK_HOME", grokHome], ["profile", profilePath], ["events", eventsPath], - ["workspace", TRAINING_SLOT_WORKSPACE], ["runtime home", TRAINING_SLOT_RUNTIME_HOME], - ["usage ledger", TRAINING_SLOT_USAGE_LEDGER]] as const) assertCanonicalRegisteredPath(label, value); + ["workspace", p.workspace], ["runtime home", p.runtimeHome], + ["usage ledger", p.usageLedger]] as const) assertCanonicalRegisteredPath(label, value); return { agentId: input.agentId, config: config.bytes, + configVariant: "clocked", ...(input.limits === undefined ? {} : { limits: { ...input.limits } }), configSha256: config.sha256, - workerConfigVariant: "clocked", deferredDenyPaths: denyPaths.filter((entry) => TRAINING_DEFERRED_DENY_PATHS.includes(entry)), denyPaths, eventsPath, @@ -135,15 +128,15 @@ export const resolveTrainingGrokRegistration = (input: TrainingGrokSlotInput): D profileSha256: daimonGrokWorkerSandboxProfileSha256(denyPaths), privateTmp: path.posix.join(home, DAIMON_GROK_ENGINE_BROKER.worker.home.privateTmp.relativeToWorkerHome), reasoningEffort: input.reasoningEffort, - runtimeHome: TRAINING_SLOT_RUNTIME_HOME, + runtimeHome: p.runtimeHome, // Empty on purpose: training has no persistent mounts at all. Everything a trial writes under the slot // runtime home is per-slot tmpfs the recycle wipes, and the only thing provisioned inside it is the // setgid `tool-output/` spill directory, so the traversable `0710` home exposes nothing else. runtimeHomeMounts: [], - slot: TRAINING_SLOT_INDEX, - spillDirectory: path.posix.join(TRAINING_SLOT_RUNTIME_HOME, DAIMON_GROK_ENGINE_BROKER.worker.home.spillDirectory.relativeToRuntimeHome), - uid: TRAINING_WORKER_UID, - usageLedgerPath: TRAINING_SLOT_USAGE_LEDGER, - workspace: TRAINING_SLOT_WORKSPACE + slot: p.slot, + spillDirectory: path.posix.join(p.runtimeHome, DAIMON_GROK_ENGINE_BROKER.worker.home.spillDirectory.relativeToRuntimeHome), + uid: p.workerUid, + usageLedgerPath: p.usageLedger, + workspace: p.workspace }; }; diff --git a/src/compiler/training/broker/reset.test.ts b/src/compiler/training/broker/reset.test.ts new file mode 100644 index 00000000..e47b51b4 --- /dev/null +++ b/src/compiler/training/broker/reset.test.ts @@ -0,0 +1,67 @@ +import { EventEmitter } from "node:events"; +import { beforeEach, expect, it, vi } from "vitest"; +import { resetTrainingBrokerSlot, resetTrainingBrokerSlotAsOrganization } from "./reset.js"; +import { DAIMON_GROK_ENGINE_BROKER } from "../../../runtime/daimon/contractManifest.js"; + +const transport = vi.hoisted(() => ({ connect: vi.fn() })); +vi.mock("node:net", () => ({ createConnection: transport.connect })); +class Socket extends EventEmitter { + write = vi.fn(); + destroy = vi.fn(); +} +let socket: Socket; +beforeEach(() => { socket = new Socket(); transport.connect.mockReturnValue(socket); }); +const start = () => { + const controller = new AbortController(); + const promise = resetTrainingBrokerSlot(1, controller.signal); + socket.emit("connect"); + const frame = socket.write.mock.calls[0]![0] as Buffer; + const request = JSON.parse(frame.subarray(4).toString()); + expect(frame.readUInt32BE(0)).toBe(frame.length - 4); + const response = { version: request.version, kind: "slot_reset", requestId: request.requestId, slot: 1 }; + return { promise, controller, request, response }; +}; +const send = (response: unknown) => { + const body = Buffer.from(JSON.stringify(response)), header = Buffer.alloc(4); + header.writeUInt32BE(body.length); socket.emit("data", header); socket.emit("data", body); +}; +it("sends the declared slot through Daimon's length-prefixed v4 protocol and verifies its acknowledgement", async () => { + const r = start(); + expect(r.request).toEqual({ version: "noopolis.daimon.engine-broker.v4", kind: "reset_slot", requestId: expect.any(String), slot: 1 }); + expect(transport.connect).toHaveBeenCalledWith(DAIMON_GROK_ENGINE_BROKER.controlSocketPath); + send(r.response); await r.promise; expect(socket.destroy).toHaveBeenCalled(); +}); +it.each([ + { version: "old" }, { kind: "completed" }, { slot: 0 }, { requestId: "wrong" }, { unexpected: true } +])("rejects an unbound acknowledgement %j", async override => { + const r = start(); send({ ...r.response, ...override }); await expect(r.promise).rejects.toThrow(/acknowledgement/); +}); +it("rejects malformed JSON, oversized frames and broker refusal", async () => { + let r = start(); socket.emit("data", Buffer.from([0, 0, 0, 2, 123, 123])); await expect(r.promise).rejects.toThrow(); + socket = new Socket(); transport.connect.mockReturnValue(socket); + r = start(); socket.emit("data", Buffer.from([0, 0, 16, 1])); await expect(r.promise).rejects.toThrow(/frame/); + socket = new Socket(); transport.connect.mockReturnValue(socket); + r = start(); socket.emit("close"); await expect(r.promise).rejects.toThrow(/slot active/); +}); +it("fails promptly on cancellation and transport errors", async () => { + let r = start(); r.controller.abort(); await expect(r.promise).rejects.toThrow(/aborted/); + socket = new Socket(); transport.connect.mockReturnValue(socket); + r = start(); socket.emit("error", Error("unavailable")); await expect(r.promise).rejects.toThrow(/unavailable/); +}); +it("refuses invalid slot indexes and already-cancelled requests before connecting", () => { + transport.connect.mockClear(); + expect(() => resetTrainingBrokerSlot(-1, new AbortController().signal)).toThrow(); + expect(() => resetTrainingBrokerSlot(1, AbortSignal.abort())).toThrow(); + expect(transport.connect).not.toHaveBeenCalled(); +}); + +it("drops root to the relay-authorized organization uid and requires the child's success sentinel", async () => { + await resetTrainingBrokerSlotAsOrganization(2, new AbortController().signal, async (command, args) => { + expect(command).toBe("setpriv"); + expect(args).toContain("--reuid=2000"); expect(args).toContain("--bounding-set=-all"); + expect(args.at(-1)).toBe("2"); + expect(args[args.indexOf("--eval") + 1]).toContain("await resetTrainingBrokerSlot"); + return { stdout: "slot-reset-complete\n" }; + }); + await expect(resetTrainingBrokerSlotAsOrganization(1, new AbortController().signal, async () => ({ stdout: "" }))).rejects.toThrow(/acknowledgement/); +}); diff --git a/src/compiler/training/broker/reset.ts b/src/compiler/training/broker/reset.ts new file mode 100644 index 00000000..973d44ff --- /dev/null +++ b/src/compiler/training/broker/reset.ts @@ -0,0 +1,59 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { randomUUID } from "node:crypto"; +import { createConnection } from "node:net"; +import { DAIMON_GROK_ENGINE_BROKER } from "../../../runtime/daimon/contractManifest.js"; +import { trainingSlotPaths } from "./paths.js"; + +/** Root uses the broker's slot reset seam; the broker owns active-turn, worker-reap and clock checks. */ +export const resetTrainingBrokerSlot = (slot: number, signal: AbortSignal, + socketPath = DAIMON_GROK_ENGINE_BROKER.controlSocketPath): Promise => { + trainingSlotPaths(slot); + signal.throwIfAborted(); + const requestId = randomUUID(); + const version = DAIMON_GROK_ENGINE_BROKER.controlProtocolVersion; + const payload = Buffer.from(JSON.stringify({ version, kind: "reset_slot", requestId, slot })); + const frame = Buffer.alloc(4 + payload.length); + frame.writeUInt32BE(payload.length); payload.copy(frame, 4); + return new Promise((resolve, reject) => { + const socket = createConnection(socketPath); + let buffer = Buffer.alloc(0), settled = false; + const finish = (error?: Error) => { + if (settled) return; + settled = true; signal.removeEventListener("abort", abort); socket.destroy(); + if (error) reject(error); else resolve(); + }; + const abort = () => finish(new Error("Training broker reset aborted; slot must be quarantined")); + signal.addEventListener("abort", abort, { once: true }); + socket.once("error", error => finish(error)); + socket.once("close", () => finish(new Error("Training broker reset refused: slot active, unavailable, or reset failed"))); + socket.once("connect", () => socket.write(frame)); + socket.on("data", chunk => { + buffer = Buffer.concat([buffer, chunk]); + if (buffer.length < 4) return; + const size = buffer.readUInt32BE(0); + if (size < 2 || size > 4096 || buffer.length > size + 4) { finish(new Error("Invalid broker reset response frame")); return; } + if (buffer.length < size + 4) return; + try { + const response = JSON.parse(buffer.subarray(4).toString("utf8")); + if (!response || response.version !== version || response.kind !== "slot_reset" || response.requestId !== requestId + || response.slot !== slot || Object.keys(response).length !== 4) throw Error("Invalid broker reset acknowledgement"); + finish(); + } catch (error) { finish(error instanceof Error ? error : new Error("Invalid broker reset response")); } + }); + }); +}; + +/** The native control relay authenticates SO_PEERCRED uid 2000 and rejects root. */ +export const resetTrainingBrokerSlotAsOrganization = async (slot: number, signal: AbortSignal, + execute: (file: string, args: string[], options: { signal: AbortSignal; timeout: number }) => Promise<{ stdout: string }> = promisify(execFile) +): Promise => { + trainingSlotPaths(slot); + signal.throwIfAborted(); + const uid = DAIMON_GROK_ENGINE_BROKER.identities.organizationUid; + const program = `import { resetTrainingBrokerSlot } from ${JSON.stringify(import.meta.url)}; await resetTrainingBrokerSlot(Number(process.argv[1]), AbortSignal.timeout(10000)); process.stdout.write("slot-reset-complete\\n");`; + const result = await execute("setpriv", ["--clear-groups", `--reuid=${uid}`, `--regid=${uid}`, + "--inh-caps=-all", "--ambient-caps=-all", "--bounding-set=-all", "--", process.execPath, + "--input-type=module", "--eval", program, String(slot)], { signal, timeout: 15_000 }); + if (result.stdout !== "slot-reset-complete\n") throw Error("Broker reset child returned no verified acknowledgement"); +}; diff --git a/src/compiler/training/broker/runtime.canaries.test.ts b/src/compiler/training/broker/runtime.canaries.test.ts new file mode 100644 index 00000000..57f70200 --- /dev/null +++ b/src/compiler/training/broker/runtime.canaries.test.ts @@ -0,0 +1,35 @@ +import { expect, it, vi } from "vitest"; +import { createTrainingSlotRuntime } from "./runtime.js"; +import { resolveTrainingGrokRegistration } from "./registration.js"; +import { parseTrainingBrokerDeclaration } from "./declaration.js"; +import { trainingSlotDescriptors } from "./paths.js"; + +const mocks = vi.hoisted(() => ({ execute: vi.fn(), read: vi.fn() })); +vi.mock("node:child_process", () => { + const execFile = Object.assign(() => undefined, { [Symbol.for("nodejs.util.promisify.custom")]: mocks.execute }); + return { execFile }; +}); +vi.mock("node:fs/promises", async importOriginal => ({ ...await importOriginal(), readFile: mocks.read })); + +it("actually probes every sibling root under each slot's own worker uid", async () => { + const declaration = parseTrainingBrokerDeclaration({ version: "spawnfile.training-broker.v2", slots: 4, engine: "grok", agentId: "agent:a", + model: "grok-4.6", reasoningEffort: "low", architecture: "arm64", limits: { maxRequests: 4, maxTokens: 100, timeoutMs: 1000 }, + bootstrap: "/var/lib/spawnfile/daimon/grok-bootstrap-auth", organizationUid: 2000, seccompProfileSha256: "a".repeat(64) }); + mocks.execute.mockResolvedValue({ stdout: "denied\n", stderr: "" }); + mocks.read.mockResolvedValue("1 0 0:1 / / rw - ext4 /dev/root rw\n"); + const slots = trainingSlotDescriptors(4); + for (const p of slots) { + mocks.execute.mockClear(); + const registration = resolveTrainingGrokRegistration({ ...declaration, slot: p.slot }); + const runtime = createTrainingSlotRuntime({ declaration, registration, projectionSha256: "b".repeat(64), reset: async () => undefined }); + const canaries = await runtime.canaries(); + for (const sibling of slots.filter(other => other.slot !== p.slot)) { + expect(canaries).toContainEqual({ path: sibling.clockDirectory, method: "sandboxed-read", result: "denied" }); + expect(mocks.execute.mock.calls.some(([command, args]) => command === "setpriv" && args.includes(`--reuid=${p.workerUid}`) + && args.at(-1).includes("bwrap --die-with-parent") && args.at(-1).includes(sibling.clockDirectory))).toBe(true); + expect(canaries).toContainEqual({ path: sibling.root, method: "sandboxed-read", result: "denied" }); + expect(mocks.execute.mock.calls.some(([command, args]) => command === "setpriv" && args.includes(`--reuid=${p.workerUid}`) + && args.at(-1).includes(`test -r '${sibling.root}'`))).toBe(true); + } + } +}); diff --git a/src/compiler/training/broker/runtime.recovery.test.ts b/src/compiler/training/broker/runtime.recovery.test.ts new file mode 100644 index 00000000..5facf821 --- /dev/null +++ b/src/compiler/training/broker/runtime.recovery.test.ts @@ -0,0 +1,211 @@ +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { EventEmitter } from "node:events"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { promisify } from "node:util"; +import { expect, it, vi } from "vitest"; +import { parse as parseYaml } from "yaml"; +import { writeBackTrainingGrokCredential } from "../../../auth/trainingGrokWriteback.js"; +import { DAIMON_GROK_ENGINE_BROKER } from "../../../runtime/daimon/contractManifest.js"; +import { parseTrainingBrokerDeclaration } from "./declaration.js"; +import { resetTrainingBrokerSlot } from "./reset.js"; +import { resolveTrainingGrokRegistration } from "./registration.js"; +import { createTrainingSlotRuntime } from "./runtime.js"; +import { createTrainingSlotSupervisor } from "./supervisor.js"; + +const transport = vi.hoisted(() => ({ connect: vi.fn() })); +vi.mock("node:net", async importOriginal => ({ + ...await importOriginal(), createConnection: transport.connect +})); + +// Exercise real v4 request/ack validation and v2 runtime/supervisor. The socket, +// native active-turn gate and root-only recycle actions are portable stand-ins. +const slotPool = async (profile: string) => { + const active = new Set(), requests: number[] = []; + transport.connect.mockImplementation((socketPath: string) => { + expect(socketPath).toBe(DAIMON_GROK_ENGINE_BROKER.controlSocketPath); + const socket = new EventEmitter(); + Object.assign(socket, { destroy: () => socket.emit("close"), write: (frame: Buffer) => { + expect(frame.readUInt32BE(0)).toBe(frame.length - 4); + const request = JSON.parse(frame.subarray(4).toString("utf8")); + expect(request).toEqual({ version: DAIMON_GROK_ENGINE_BROKER.controlProtocolVersion, + kind: "reset_slot", requestId: expect.any(String), slot: expect.any(Number) }); + requests.push(request.slot); + if (active.has(request.slot)) { socket.emit("close"); return; } + const body = Buffer.from(JSON.stringify({ version: request.version, kind: "slot_reset", + requestId: request.requestId, slot: request.slot })); + const header = Buffer.alloc(4); header.writeUInt32BE(body.length); + socket.emit("data", header); socket.emit("data", body); + } }); + queueMicrotask(() => socket.emit("connect")); + return socket; + }); + const declaration = parseTrainingBrokerDeclaration({ version: "spawnfile.training-broker.v2", slots: 2, + engine: "grok", agentId: "agent:fixture", model: "grok-4.6", reasoningEffort: "low", architecture: "arm64", + bootstrap: profile, organizationUid: 2000, seccompProfileSha256: "a".repeat(64), + limits: { maxRequests: 10, maxTokens: 10000, timeoutMs: 60000 } }); + const order: string[][] = [[], []]; + const runtimes = order.map((steps, slot) => { + const runtime = createTrainingSlotRuntime({ declaration, + registration: resolveTrainingGrokRegistration({ ...declaration, slot }), projectionSha256: "a".repeat(64), + reset: async (index, signal) => { await resetTrainingBrokerSlot(index, signal); steps.push("reset"); } + }); + return { ...runtime, + wipe: vi.fn(async () => { steps.push("wipe"); }), provision: vi.fn(async () => { steps.push("provision"); }), + canaries: async () => [], nextGeneration: async () => 1, + publishReceipt: async () => { steps.push("receipt"); return `fixture-receipt-${slot}`; }, log: () => undefined + }; + }); + return { active, requests, order, supervisor: createTrainingSlotSupervisor({ runtimes, drainTimeoutMs: 1000 }) }; +}; + +it("recycles a leased slot beside an active sibling without draining or modifying the shared credential journal", async () => { + const root = await realpath(await mkdtemp(path.join(tmpdir(), "broker-slots-"))); + const profile = path.join(root, "profile"), journal = path.join(root, "journal"), auth = path.join(root, "auth"); + const original = JSON.stringify({ "https://auth.x.ai::fixture": { + key: "synthetic", refresh_token: "synthetic", expires_at: "2030-01-01T00:00:00Z" } }); + const refreshed = original.replaceAll("synthetic", "synthetic-renewed"); + const pending = JSON.stringify({ version: "noopolis.daimon.broker-credential-journal.v1", + state: "refreshing", generation: 1, sourceDigest: "a".repeat(64) }); + let pool: Awaited> | undefined; + try { + await writeFile(profile, original, { mode: 0o600 }); await writeFile(auth, refreshed, { mode: 0o600 }); + await writeFile(journal, pending, { mode: 0o600 }); + pool = await slotPool(profile); pool.active.add(0); pool.active.add(1); + await expect(pool.supervisor.recycle(1, "a".repeat(64), 2000)).rejects.toThrow(/slot active/); + expect(pool.order).toEqual([[], []]); + pool.active.delete(1); + await expect(pool.supervisor.recycle(1, "b".repeat(64), 2000)).resolves.toMatchObject({ ok: true, slot: 1 }); + expect(pool.requests).toEqual([1, 1]); + expect(pool.order).toEqual([[], ["reset", "wipe", "provision", "receipt"]]); + expect(pool.active.has(0)).toBe(true); + // Slot reset is independent of credential recovery; unsettled auth still cannot be exported. + await expect(writeBackTrainingGrokCredential(profile, auth, journal)).rejects.toThrow(/writeback failed/); + expect(await readFile(profile, "utf8")).toBe(original); + expect(await readFile(auth, "utf8")).toBe(refreshed); + expect(await readFile(journal, "utf8")).toBe(pending); + } finally { transport.connect.mockReset(); await rm(root, { recursive: true, force: true }); } +}); + +// Read the broker as test data from its exact registry revision, never from a +// checkout's mutable HEAD. CI supplies the repository; local runs may select it +// with DAIMON_SOURCE_ROOT. Missing source is a failure, not skipped coverage. +const stagePinnedDaimon = async (root: string): Promise => { + const registry = parseYaml(await readFile("runtimes.yaml", "utf8")) as { + runtimes: { daimon: { ref: string } } + }; + const ref = registry.runtimes.daimon.ref; + if (!/^[a-f0-9]{40}$/u.test(ref)) throw Error("Daimon recovery requires a full registry commit pin"); + const source = process.env.DAIMON_SOURCE_ROOT ?? path.resolve("..", `daimon-${ref.slice(0, 7)}`); + const run = promisify(execFile); + let revision: string; + try { revision = (await run("git", ["-C", source, "rev-parse", `${ref}^{commit}`])).stdout.trim(); } + catch { throw Error(`Daimon recovery requires Git source containing ${ref}; set DAIMON_SOURCE_ROOT`); } + expect(revision).toBe(ref); + const archive = path.join(root, "daimon.tar"), staged = path.join(root, "daimon"); + await mkdir(staged); + await run("git", ["-C", source, "archive", "--format=tar", `--output=${archive}`, ref, "src", "package.json"]); + await run("tar", ["-xf", archive, "-C", staged]); + await symlink(path.resolve("node_modules"), path.join(staged, "node_modules"), "dir"); + return staged; +}; + +it("restart without refresh permits profile writeback and slot recycling at the exact Daimon pin", async () => { + const root = await realpath(await mkdtemp(path.join(tmpdir(), "broker-recovery-"))); + let pool: Awaited> | undefined; + try { + const daimon = await stagePinnedDaimon(root); + const home = path.join(root, "realm"); + await mkdir(home); + const auth = path.join(home, "auth.json"), profile = path.join(root, "profile.json"); + const journal = path.join(home, ".daimon-broker", "credential-journal.json"); + const original = JSON.stringify({ "https://auth.x.ai::fixture": { + key: "a".repeat(40), refresh_token: "r".repeat(20), expires_at: "2030-01-01T00:00:00Z" + } }); + await writeFile(profile, original, { mode: 0o600 }); + await writeFile(auth, original, { mode: 0o600 }); + const command = path.join(root, "rotate.cjs"); + await writeFile(command, `#!/usr/bin/env node +const fs = require("node:fs"), path = require("node:path"); +const file = path.join(process.env.GROK_HOME, "auth.json"); +const auth = JSON.parse(fs.readFileSync(file, "utf8")); +Object.values(auth)[0].key = "b".repeat(40); +fs.writeFileSync(file, JSON.stringify(auth)); +`, { mode: 0o700 }); + const moduleUrl = pathToFileURL(path.join(daimon, "src/runtime/grokBrokerCredentialAuthority.ts")).href; + // Two processes model the crash/restart boundary. No credential bytes are + // returned in stdout; the second process cannot invoke a refresh command. + const runAuthority = async (body: string) => promisify(execFile)(process.execPath, + ["--import", "tsx", "--input-type=module", "--eval", ` +import { DurableGrokBrokerCredentialAuthority as Authority } from ${JSON.stringify(moduleUrl)}; +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +const home = ${JSON.stringify(home)}, auth = ${JSON.stringify(auth)}, journal = ${JSON.stringify(journal)}; +${body}`], { cwd: daimon, timeout: 15_000, env: { ...process.env, TSX_DISABLE_CACHE: "1" } }); + await runAuthority(` +await new Authority(${JSON.stringify(command)}, home).accessToken(true); +const row = JSON.parse(await readFile(journal, "utf8")); +row.state = "refreshing"; +row.sourceDigest = createHash("sha256").update(await readFile(auth)).digest("hex"); +delete row.promotedDigest; +await writeFile(journal, JSON.stringify(row));`); + expect(JSON.parse(await readFile(journal, "utf8")).state).toBe("refreshing"); + await expect(writeBackTrainingGrokCredential(profile, auth, journal)).rejects.toThrow(/writeback failed/); + await runAuthority('await new Authority("/nonexistent/grok", home).accessToken(false);'); + expect(JSON.parse(await readFile(journal, "utf8"))).toMatchObject({ state: "promoted", generation: 1 }); + // Equal expiry deliberately requires the preserved ancestry proof. + expect(await writeBackTrainingGrokCredential(profile, auth, journal)).toBe(true); + expect(await readFile(profile, "utf8")).toBe(await readFile(auth, "utf8")); + expect(await readFile(profile, "utf8")).not.toBe(original); + pool = await slotPool(profile); pool.active.add(0); + const settled = await readFile(journal, "utf8"), refreshed = await readFile(auth, "utf8"); + await expect(pool.supervisor.recycle(1, "a".repeat(64), 2000)).resolves.toMatchObject({ ok: true, slot: 1, generation: 1 }); + expect(pool.requests).toEqual([1]); + expect(pool.order).toEqual([[], ["reset", "wipe", "provision", "receipt"]]); + expect(await readFile(journal, "utf8")).toBe(settled); + expect(await readFile(auth, "utf8")).toBe(refreshed); + } finally { transport.connect.mockReset(); await rm(root, { recursive: true, force: true }); } +}); + +it("two equal-expiry rotations across restart retain ancestry to the original host credential at the exact Daimon pin", async () => { + const root = await realpath(await mkdtemp(path.join(tmpdir(), "broker-lineage-"))); + try { + const daimon = await stagePinnedDaimon(root), home = path.join(root, "realm"); + await mkdir(home); + const auth = path.join(home, "auth.json"), profile = path.join(root, "profile.json"); + const journal = path.join(home, ".daimon-broker", "credential-journal.json"); + const original = JSON.stringify({ "https://auth.x.ai::fixture": { + key: "a".repeat(40), refresh_token: "r".repeat(20), expires_at: "2030-01-01T00:00:00Z" + } }); + await writeFile(profile, original, { mode: 0o600 }); + await writeFile(auth, original, { mode: 0o600 }); + const command = path.join(root, "rotate.cjs"); + await writeFile(command, `#!/usr/bin/env node +const fs = require("node:fs"), path = require("node:path"); +const file = path.join(process.env.GROK_HOME, "auth.json"); +const auth = JSON.parse(fs.readFileSync(file, "utf8")), row = Object.values(auth)[0]; +row.key = String.fromCharCode(row.key.charCodeAt(0) + 1).repeat(40); +fs.writeFileSync(file, JSON.stringify(auth)); +`, { mode: 0o700 }); + const moduleUrl = pathToFileURL(path.join(daimon, "src/runtime/grokBrokerCredentialAuthority.ts")).href; + // Each rotation gets a fresh process, so lineage must survive on disk. + for (let generation = 1; generation <= 2; generation++) { + await promisify(execFile)(process.execPath, ["--import", "tsx", "--input-type=module", "--eval", ` +import { DurableGrokBrokerCredentialAuthority as Authority } from ${JSON.stringify(moduleUrl)}; +await new Authority(${JSON.stringify(command)}, ${JSON.stringify(home)}).accessToken(true); +`], { cwd: daimon, timeout: 15_000, env: { ...process.env, TSX_DISABLE_CACHE: "1" } }); + expect(JSON.parse(await readFile(journal, "utf8"))).toMatchObject({ state: "promoted", generation }); + } + const row = JSON.parse(await readFile(journal, "utf8")); + const { createHash } = await import("node:crypto"); + const originalDigest = createHash("sha256").update(original).digest("hex"); + expect(row.sourceDigest).not.toBe(originalDigest); + expect(row.ancestorDigests).toContain(originalDigest); + expect(await writeBackTrainingGrokCredential(profile, auth, journal)).toBe(true); + expect(await readFile(profile, "utf8")).toBe(await readFile(auth, "utf8")); + expect(await readFile(profile, "utf8")).not.toBe(original); + expect(await writeBackTrainingGrokCredential(profile, auth, journal)).toBe(false); + } finally { await rm(root, { recursive: true, force: true }); } +}); diff --git a/src/compiler/training/broker/runtime.test.ts b/src/compiler/training/broker/runtime.test.ts new file mode 100644 index 00000000..962b93fb --- /dev/null +++ b/src/compiler/training/broker/runtime.test.ts @@ -0,0 +1,74 @@ +import { execFile, spawn } from "node:child_process"; +import { mkdtemp, mkdir, writeFile, readFile, lstat, rm, symlink, realpath } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { expect, it } from "vitest"; +import { renderTrainingSlotWipe, probeTrainingWorker } from "./runtime.js"; +import { trainingSlotPaths } from "./paths.js"; + +const run = promisify(execFile); +const fixture = async () => { + const root = await realpath(await mkdtemp(path.join(os.tmpdir(), "slot-wipe-"))); + const mapped = (p: string) => `${root}${p}`; + for (let slot = 0; slot < 4; slot++) { + const p = trainingSlotPaths(slot); + for (const dir of [p.workerHome, p.privateTmp, p.runtimeHome, p.state, p.workspace, p.usageDirectory, `${p.workerHome}/.grok`]) { + await mkdir(mapped(dir), { recursive: true }); + await writeFile(`${mapped(dir)}/trial-data`, `slot-${slot}`); + } + for (const file of [p.preflightReceipt, p.sealedInputsAttestation, p.generationFile]) await writeFile(mapped(file), `slot-${slot}`); + } + const script = renderTrainingSlotWipe(1).join("\n") + .replaceAll("/run/training", `${root}/run/training`).replaceAll("/var/lib/daimon-workers", `${root}/var/lib/daimon-workers`) + .replace("spawnfile_mount_points=$(awk '{print $5}' /proc/self/mountinfo)", "spawnfile_mount_points=''"); + // Execute the actual wipe with privilege operations stubbed: this portable test proves deletion scope, + // inode retention and symlink refusal. Linux uid/capability enforcement is a separate integration gate. + const harness = "chown() { :; }\nsetpriv() { while [ \"$1\" != -- ]; do shift; done; shift; \"$@\"; }\n" + script; + return { root, mapped, harness }; +}; + +it("executes slot 1's wipe while retaining sibling files, worker tmp inodes and running broker stand-ins", async () => { + const f = await fixture(); + const children = Array.from({ length: 3 }, () => spawn(process.execPath, ["-e", "setInterval(()=>{},1000)"], { stdio: "ignore" })); + try { + const home = trainingSlotPaths(1).workerHome, tmp = trainingSlotPaths(1).privateTmp; + const before = await Promise.all([home, tmp].map(p => lstat(f.mapped(p)))); + await run("/bin/bash", ["-ceu", f.harness]); + for (const i of [0, 2, 3]) { + const p = trainingSlotPaths(i); + for (const dir of [p.workerHome, p.privateTmp, p.workspace, p.runtimeHome, p.state, p.usageDirectory]) + expect(await readFile(`${f.mapped(dir)}/trial-data`, "utf8")).toBe(`slot-${i}`); + } + for (const dir of [home, tmp, trainingSlotPaths(1).workspace, trainingSlotPaths(1).usageDirectory]) + await expect(readFile(`${f.mapped(dir)}/trial-data`)).rejects.toMatchObject({ code: "ENOENT" }); + const after = await Promise.all([home, tmp].map(p => lstat(f.mapped(p)))); + expect(after.map(s => [s.ino, s.uid, s.mode])).toEqual(before.map(s => [s.ino, s.uid, s.mode])); + expect(await readFile(f.mapped(trainingSlotPaths(1).generationFile), "utf8")).toBe("slot-1"); + for (const child of children) { expect(child.exitCode).toBeNull(); expect(process.kill(child.pid!, 0)).toBe(true); } + } finally { for (const child of children) child.kill(); await rm(f.root, { recursive: true, force: true }); } +}); + +it("refuses a symlinked registered wipe root before touching its target", async () => { + const f = await fixture(); + try { + const p = trainingSlotPaths(1), sibling = trainingSlotPaths(0); + await rm(f.mapped(p.workspace), { recursive: true }); + await symlink(f.mapped(sibling.workspace), f.mapped(p.workspace)); + await expect(run("/bin/bash", ["-ceu", f.harness])).rejects.toThrow(); + expect(await readFile(`${f.mapped(sibling.workspace)}/trial-data`, "utf8")).toBe("slot-0"); + } finally { await rm(f.root, { recursive: true, force: true }); } +}); + +it("requires an explicit worker-uid denial verdict; failed execution and empty output never certify isolation", async () => { + expect(await probeTrainingWorker(2201, "/run/training/slots/0", "read", async (_cmd, args) => { + expect(args).toContain("--reuid=2201"); + expect(args.at(-1)).toContain("test -r '/run/training/slots/0'"); + return { stdout: "denied\n" }; + })).toBe(true); + expect(await probeTrainingWorker(2201, "/run/training/inputs", "enter", async (_cmd, args) => { + expect(args.at(-1)).toContain("test -x"); return { stdout: "reachable\n" }; + })).toBe(false); + await expect(probeTrainingWorker(2201, "/run/training/slots/0", "read", async () => ({ stdout: "" }))).rejects.toThrow(/verdict/); + await expect(probeTrainingWorker(2201, "/run/training/slots/0", "read", async () => { throw Error("setpriv failed"); })).rejects.toThrow(/setpriv failed/); +}); diff --git a/src/compiler/training/broker/runtime.ts b/src/compiler/training/broker/runtime.ts index 1e81dff5..c0b24699 100644 --- a/src/compiler/training/broker/runtime.ts +++ b/src/compiler/training/broker/runtime.ts @@ -1,33 +1,24 @@ import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; import { appendFileSync } from "node:fs"; -import { chmod, chown, mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises"; +import { chmod, chown, mkdir, readFile, rename, writeFile } from "node:fs/promises"; import path from "node:path"; -import { setTimeout as pause } from "node:timers/promises"; import { promisify } from "node:util"; import { SpawnfileError } from "../../../shared/index.js"; -import { DAIMON_GROK_ENGINE_BROKER } from "../../../runtime/daimon/contractManifest.js"; import type { DaimonGrokRegistration } from "../../containerDaimonGrokWorkerRender.js"; import type { TrainingBrokerDeclaration } from "./declaration.js"; import { MOUNT_AWARE_CLEAR_HELPER } from "../../containerDaimonBrokerRender.js"; -import { renderTrainingBrokerProvisioning, renderTrainingIdentities } from "./provisioning.js"; -import { startBrokerProcesses, stopBrokerProcesses, type BrokerChild } from "./processes.js"; -import { buildTrainingSlotReceipt, readGrokExecutableSha256, resolveTrainingCanaries } from "./receipt.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"; import { DAIMON_ORGANIZATION_UID, TRAINING_HOST_BIND_DENY_PATHS, TRAINING_SEALED_DENY_PATHS, - TRAINING_SLOT_ACCEPTANCE_STORE, - TRAINING_WIPE_TARGETS, - TRAINING_SLOT_GENERATION_FILE, - TRAINING_SLOT_PREFLIGHT_RECEIPT, - TRAINING_SLOT_RUNTIME_HOME, - TRAINING_SLOT_TURN_STORE, - TRAINING_SLOT_USAGE_DIRECTORY, - TRAINING_SLOT_WORKSPACE, + trainingSlotPaths, trainingWipeTargets, trainingClearTargets, TRAINING_SUPERVISOR_LOG } from "./paths.js"; @@ -38,111 +29,35 @@ export interface TrainingSlotRuntimeOptions { declaration: TrainingBrokerDeclaration; registration: DaimonGrokRegistration; projectionSha256: string; + reset: (slot: number, signal: AbortSignal) => Promise; /** Test seam: the shell the provisioning script runs in and the `/proc` it reads identities from. */ shell?: string; procRoot?: string; logPath?: string; } -const CREDENTIAL_JOURNAL = `${DAIMON_GROK_ENGINE_BROKER.credentialHomePath}/.daimon-broker/credential-journal.json`; - -/** State the broker's credential authority may be left in; only `promoted` (or no journal at all) is settled. */ -const settledJournal = async (): Promise<"settled" | "refreshing" | "stale"> => { - const raw = await readFile(CREDENTIAL_JOURNAL, "utf8").catch((error: NodeJS.ErrnoException) => { - if (error.code === "ENOENT") return undefined; - throw error; - }); - if (raw === undefined) return "settled"; - const journal = JSON.parse(raw) as { version?: unknown; state?: unknown }; - if (journal.version !== "noopolis.daimon.broker-credential-journal.v1") throw new SpawnfileError("runtime_error", "The broker credential journal is not a recognised version"); - return journal.state === "promoted" ? "settled" : journal.state === "refreshing" ? "refreshing" : "stale"; -}; - -/** A turn is active while its registry record says so; a terminal record is finished work and never blocks a recycle. */ -const activeTurns = async (): Promise => { - const names = await readdir(TRAINING_SLOT_TURN_STORE).catch((error: NodeJS.ErrnoException) => { - if (error.code === "ENOENT") return [] as string[]; - throw error; - }); - let active = 0; - for (const name of names) { - if (!name.endsWith(".json")) continue; - const raw = await readFile(path.join(TRAINING_SLOT_TURN_STORE, name), "utf8").catch(() => ""); - try { if ((JSON.parse(raw) as { state?: unknown }).state === "active") active += 1; } catch { /* a half-written record is not an active turn */ } - } - return active; -}; - -/** - * The real slot runtime: what `recycle` actually does to this container. - * - * Every mutation goes through the same rendered provisioning the root - * entrypoint ran at startup, so a recycled slot is byte-identically - * provisioned — including the credential journal recovery, which is how a - * recycle that lands on a `refreshing` or `stale` realm either recovers or - * fails closed with a named error instead of leaving a silently stale realm. - */ -export const createTrainingSlotRuntime = (options: TrainingSlotRuntimeOptions): TrainingSlotRuntime & { children(): readonly BrokerChild[] } => { +export const createTrainingSlotRuntime = (options: TrainingSlotRuntimeOptions): TrainingSlotRuntime => { const shell = options.shell ?? "/bin/bash"; const logPath = options.logPath ?? TRAINING_SUPERVISOR_LOG; - const script = [...renderTrainingIdentities(), ...renderTrainingBrokerProvisioning(options.registration)].join("\n"); - let children: BrokerChild[] = []; + const p = trainingSlotPaths(options.registration.slot); + const script = renderTrainingSlotProvisioning(options.registration).join("\n"); const log = (line: string): void => { const entry = `${new Date().toISOString()} ${line}\n`; process.stderr.write(`[slot-supervisor] ${entry}`); try { appendFileSync(logPath, entry, { mode: 0o640 }); } catch { /* the log is diagnostics, never the gate */ } }; - /** - * One worker-uid reachability attempt. The command below *succeeds* when the - * worker can still reach the target, so a non-zero exit is the denial. - * - * `enter` is the stronger question a sealed root needs answered: a directory - * can be unreadable (`test -r` fails) and still searchable, and a subject - * that knows `test.paideia.yaml` by name needs only search permission. - */ - const probe = async (target: string, depth: "read" | "enter"): Promise => { - const quoted = JSON.stringify(target); - const reachable = depth === "enter" - ? `test -r ${quoted} || test -x ${quoted} || ls -1 ${quoted} >/dev/null 2>&1` - : `test -r ${quoted}`; - try { - await run("setpriv", ["--clear-groups", `--reuid=${options.registration.uid}`, `--regid=${options.registration.uid}`, - "--inh-caps=-all", "--ambient-caps=-all", "--bounding-set=-all", "--", "/bin/sh", "-c", reachable], { timeout: 10_000 }); - return false; - } catch { return true; } - }; + const siblingClocks = Array.from({ length: options.declaration.slots }, (_, slot) => trainingSlotPaths(slot)) + .filter(peer => peer.slot !== p.slot).map(peer => peer.clockDirectory); + const probe = (target: string, depth: "read" | "enter") => siblingClocks.includes(target) + ? probeTrainingSiblingClock(p.slot, target) + : probeTrainingWorker(options.registration.uid, target, depth); return { log, - children: () => children, - drain: async (signal) => { - for (;;) { - signal.throwIfAborted(); - const state = await settledJournal(); - if (state === "stale") { - // The audited provisioning below owns stale-realm recovery; it promotes the bootstrap or refuses outright. - log("credential journal is stale; the recycle will replay the root credential recovery"); - return; - } - if (state === "settled" && await activeTurns() === 0) return; - await pause(100); - } - }, - stop: async () => { await stopBrokerProcesses(children, log); children = []; }, + reset: signal => options.reset(p.slot, signal), wipe: async () => { // Root holds `CAP_CHOWN` and `CAP_DAC_READ_SEARCH` but not `CAP_DAC_OVERRIDE`, so it cannot delete // inside a directory it handed to the worker or the broker. Reclaim each tree first, then remove it. - const targets = TRAINING_WIPE_TARGETS; - const contents = [TRAINING_SLOT_WORKSPACE, TRAINING_SLOT_USAGE_DIRECTORY]; - const script = [ - ...MOUNT_AWARE_CLEAR_HELPER, - // The parent has to be reclaimed too: unlinking is a write to the *directory*, and the slot hands - // several of these parents to uid 2000 or the broker. Provisioning restores every mode right after. - 'reclaim() { parent=$(dirname "$1"); chown 0:0 "$parent"; chmod u+rwx "$parent"; if [ -e "$1" ]; then chown -R 0:0 "$1"; chmod -R u+rwX "$1"; fi; }', - ...targets.map((target) => `reclaim ${quote(target)}; spawnfile_remove_tree ${quote(target)}`), - // The workspace and the per-slot ledger directory keep their own root: the registered paths must - // stay canonical across a recycle, so only what a trial put inside them goes. - ...contents.map((target) => `reclaim ${quote(target)}; spawnfile_clear_tree ${quote(target)}`) - ].join("\n"); + const script = renderTrainingSlotWipe(p.slot).join("\n"); await run(shell, ["--noprofile", "--norc", "-ceu", script], { maxBuffer: 1024 * 1024 }).catch((error: unknown) => { const detail = error as { stderr?: string }; throw new SpawnfileError("runtime_error", `Training slot wipe failed: ${(detail.stderr ?? "").slice(-1024).trim() || "no diagnostic"}`); @@ -155,22 +70,21 @@ export const createTrainingSlotRuntime = (options: TrainingSlotRuntimeOptions): }); if (result.stdout.trim()) log(`provisioning: ${result.stdout.trim().slice(-2048)}`); }, - start: async () => { children = await startBrokerProcesses({ log, procRoot: options.procRoot }); }, 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 + 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(TRAINING_SLOT_GENERATION_FILE, "utf8").catch(() => ""); + const previous = await readFile(p.generationFile, "utf8").catch(() => ""); const parsed = Number.parseInt((JSON.parse(previous || "{}") as { generation?: unknown }).generation as string ?? "0", 10); // Seeded from the wall clock so a container restart cannot hand out a generation an evaluator already accepted. const generation = Math.max(Number.isSafeInteger(parsed) && parsed > 0 ? parsed + 1 : 1, Math.floor(Date.now() / 1000)); - await writeFile(TRAINING_SLOT_GENERATION_FILE, `${JSON.stringify({ generation })}\n`, { mode: 0o600 }); + await writeFile(p.generationFile, `${JSON.stringify({ generation })}\n`, { mode: 0o600 }); return generation; }, publishReceipt: async ({ generation, nonce, canaries }) => { @@ -180,13 +94,56 @@ export const createTrainingSlotRuntime = (options: TrainingSlotRuntimeOptions): seccompProfileSha256: options.declaration.seccompProfileSha256, grokExecutableSha256: readGrokExecutableSha256(), canaries, createdAt: new Date() }); - const temporary = `${TRAINING_SLOT_PREFLIGHT_RECEIPT}.${randomUUID()}.tmp`; - await mkdir(path.dirname(TRAINING_SLOT_PREFLIGHT_RECEIPT), { recursive: true }); + const temporary = `${p.preflightReceipt}.${randomUUID()}.tmp`; + await mkdir(path.dirname(p.preflightReceipt), { recursive: true }); await writeFile(temporary, `${JSON.stringify(receipt)}\n`, { mode: 0o600, flag: "wx" }); await chown(temporary, 0, DAIMON_ORGANIZATION_UID); await chmod(temporary, 0o640); - await rename(temporary, TRAINING_SLOT_PREFLIGHT_RECEIPT); - return TRAINING_SLOT_PREFLIGHT_RECEIPT; + await rename(temporary, p.preflightReceipt); + return p.preflightReceipt; } }; }; + +/** Never remove or change the ownership/mode of a worker home or tmp: siblings attest those inodes. */ +export const renderTrainingSlotWipe = (slot: number): string[] => { + const p = trainingSlotPaths(slot); + const worker = `setpriv --clear-groups --reuid=${p.workerUid} --regid=${p.workerUid} --inh-caps=-all --ambient-caps=-all --bounding-set=-all --`; + return [ + ...MOUNT_AWARE_CLEAR_HELPER, + // Attested roots must still be canonical before any recursive mutation. + ...[p.workerHome, p.privateTmp, ...trainingWipeTargets(slot), ...trainingClearTargets(slot)].map(target => + `if [ -e ${quote(target)} ] || [ -L ${quote(target)} ]; then if [ -L ${quote(target)} ] || [ "$(realpath ${quote(target)})" != ${quote(target)} ]; then echo "noncanonical slot wipe target" >&2; exit 1; fi; fi`), + 'reclaim() { parent=$(dirname "$1"); chown 0:0 "$parent"; chmod u+rwx "$parent"; if [ -e "$1" ]; then chown -R 0:0 "$1"; chmod -R u+rwX "$1"; fi; }', + ...trainingWipeTargets(slot).map(target => `reclaim ${quote(target)}; spawnfile_remove_tree ${quote(target)}`), + ...trainingClearTargets(slot).map(target => `reclaim ${quote(target)}; spawnfile_clear_tree ${quote(target)}`), + // Reclaim descendants only, then unlink as the worker, who owns the stable parent. No sibling inode changes. + `for worker_entry in ${quote(p.workerHome)}/* ${quote(p.workerHome)}/.[!.]* ${quote(p.workerHome)}/..?*; do`, + ' if [ ! -e "$worker_entry" ] && [ ! -L "$worker_entry" ]; then continue; fi', + ` if [ "$worker_entry" = ${quote(p.privateTmp)} ]; then continue; fi`, + ' if spawnfile_holds_mount "$worker_entry"; then echo "worker home contains a mount" >&2; exit 1; fi', + ` chown -hR ${p.workerUid}:${p.workerUid} "$worker_entry"`, + ` ${worker} /bin/sh -ceu 'if [ ! -L "$1" ]; then chmod -R u+rwX "$1"; fi; rm -rf "$1"' sh "$worker_entry"`, + 'done', + `for worker_entry in ${quote(p.privateTmp)}/* ${quote(p.privateTmp)}/.[!.]* ${quote(p.privateTmp)}/..?*; do`, + ' if [ ! -e "$worker_entry" ] && [ ! -L "$worker_entry" ]; then continue; fi', + ' if spawnfile_holds_mount "$worker_entry"; then echo "worker tmp contains a mount" >&2; exit 1; fi', + ` chown -hR ${p.workerUid}:${p.workerUid} "$worker_entry"`, + ` ${worker} /bin/sh -ceu 'if [ ! -L "$1" ]; then chmod -R u+rwX "$1"; fi; rm -rf "$1"' sh "$worker_entry"`, + 'done' + ]; +}; + +/** A failed privilege drop is not evidence of denial; require a positive worker verdict. */ +export const probeTrainingWorker = async (uid: number, target: string, depth: "read" | "enter", + execute: (command: string, args: string[], options: { timeout: number }) => Promise<{ stdout: string }> = run +): Promise => { + const q = quote(target); + const reachable = depth === "enter" ? `test -r ${q} || test -x ${q} || ls -1 ${q} >/dev/null 2>&1` : `test -r ${q}`; + const result = await execute("setpriv", ["--clear-groups", `--reuid=${uid}`, `--regid=${uid}`, + "--inh-caps=-all", "--ambient-caps=-all", "--bounding-set=-all", "--", "/bin/sh", "-c", + `if ${reachable}; then echo reachable; else echo denied; fi`], { timeout: 10_000 }); + if (result.stdout.trim() === "denied") return true; + if (result.stdout.trim() === "reachable") return false; + throw new SpawnfileError("runtime_error", "Training worker canary returned no verified verdict"); +}; diff --git a/src/compiler/training/broker/supervisor.test.ts b/src/compiler/training/broker/supervisor.test.ts index 8b3ffeaf..62a95f75 100644 --- a/src/compiler/training/broker/supervisor.test.ts +++ b/src/compiler/training/broker/supervisor.test.ts @@ -8,14 +8,12 @@ const stubRuntime = (overrides: Partial = {}) => { const order: string[] = []; const state = { leftovers: ["trial-1.json"], generation: 0 }; const runtime: TrainingSlotRuntime = { - drain: async () => { order.push("drain"); }, - stop: async () => { order.push("stop"); }, + reset: async () => { order.push("drain"); }, wipe: async () => { order.push("wipe"); state.leftovers = []; }, provision: async () => { order.push("provision"); }, - start: async () => { order.push("start"); }, canaries: async () => { order.push("canaries"); return [{ path: "/run/paideia/context.json", method: "sandboxed-read" as const, result: "denied" as const }]; }, nextGeneration: async () => { order.push("generation"); state.generation += 1; return state.generation; }, - publishReceipt: async () => { order.push("receipt"); return "/run/training/slot/preflight.json"; }, + publishReceipt: async () => { order.push("receipt"); return "/run/training/slots/0/preflight.json"; }, log: () => undefined, ...overrides }; @@ -23,10 +21,10 @@ const stubRuntime = (overrides: Partial = {}) => { }; describe("training slot recycle", () => { - it("drains, stops, wipes, re-provisions, restarts, runs canaries and only then publishes the receipt", async () => { + it("resets broker slot state, wipes, reprovisions, canaries and only then publishes the receipt", async () => { const { runtime, order, state } = stubRuntime(); - const result = await createTrainingSlotSupervisor({ runtime }).recycle(nonce, 2000); - expect(order).toEqual(["drain", "stop", "wipe", "provision", "start", "canaries", "generation", "receipt"]); + const result = await createTrainingSlotSupervisor({ runtimes: [runtime] }).recycle(0, nonce, 2000); + expect(order).toEqual(["drain", "wipe", "provision", "canaries", "generation", "receipt"]); // Mutation guard: a recycle that left the previous trial's state behind must not reach a receipt. expect(state.leftovers).toEqual([]); expect(result).toMatchObject({ ok: true, verb: "recycle", generation: 1, nonce }); @@ -34,45 +32,45 @@ describe("training slot recycle", () => { it("refuses a caller that is not the organization uid", async () => { const { runtime, order } = stubRuntime(); - await expect(createTrainingSlotSupervisor({ runtime }).recycle(nonce, 2200)).rejects.toThrow(/refused for uid 2200/u); + await expect(createTrainingSlotSupervisor({ runtimes: [runtime] }).recycle(0, nonce, 2200)).rejects.toThrow(/refused for uid 2200/u); expect(order).toEqual([]); }); it("refuses a nonce that is not 32 random bytes of hex", async () => { const { runtime, order } = stubRuntime(); for (const bad of ["", "not-hex", "A".repeat(64), "a".repeat(63)]) { - await expect(createTrainingSlotSupervisor({ runtime }).recycle(bad, 2000)).rejects.toThrow(/hex nonce/u); + await expect(createTrainingSlotSupervisor({ runtimes: [runtime] }).recycle(0, bad, 2000)).rejects.toThrow(/hex nonce/u); } expect(order).toEqual([]); }); it("never publishes a receipt when a canary is still reachable", async () => { const { runtime, order } = stubRuntime({ canaries: async () => { throw Error("Grok slot canary /run/training/output is still readable by the worker uid"); } }); - await expect(createTrainingSlotSupervisor({ runtime }).recycle(nonce, 2000)).rejects.toThrow(/still readable/u); + await expect(createTrainingSlotSupervisor({ runtimes: [runtime] }).recycle(0, nonce, 2000)).rejects.toThrow(/still readable/u); expect(order).not.toContain("receipt"); }); it("aborts the drain at its deadline instead of recycling under an active turn", async () => { const drain = vi.fn(async (signal: AbortSignal) => { await new Promise((resolve, reject) => { signal.addEventListener("abort", () => reject(Error("drain aborted"))); setTimeout(resolve, 5_000); }); }); - const { runtime, order } = stubRuntime({ drain }); - await expect(createTrainingSlotSupervisor({ runtime, drainTimeoutMs: 20 }).recycle(nonce, 2000)).rejects.toThrow(/drain aborted/u); + const { runtime, order } = stubRuntime({ reset: drain }); + await expect(createTrainingSlotSupervisor({ runtimes: [runtime], drainTimeoutMs: 20 }).recycle(0, nonce, 2000)).rejects.toThrow(/drain aborted/u); expect(order).not.toContain("wipe"); }); it("serializes overlapping recycles instead of interleaving a wipe with a provision", async () => { const { runtime, order } = stubRuntime(); - const supervisor = createTrainingSlotSupervisor({ runtime }); - await Promise.all([supervisor.recycle(nonce, 2000), supervisor.recycle("b".repeat(64), 2000)]); - expect(order.join(",")).toBe(["drain", "stop", "wipe", "provision", "start", "canaries", "generation", "receipt"].concat( - ["drain", "stop", "wipe", "provision", "start", "canaries", "generation", "receipt"]).join(",")); + const supervisor = createTrainingSlotSupervisor({ runtimes: [runtime] }); + await Promise.all([supervisor.recycle(0, nonce, 2000), supervisor.recycle(0, "b".repeat(64), 2000)]); + expect(order.join(",")).toBe(["drain", "wipe", "provision", "canaries", "generation", "receipt"].concat( + ["drain", "wipe", "provision", "canaries", "generation", "receipt"]).join(",")); }); it("keeps the queue usable after a failed recycle", async () => { let fail = true; const { runtime } = stubRuntime({ provision: async () => { if (fail) { fail = false; throw Error("provisioning failed"); } } }); - const supervisor = createTrainingSlotSupervisor({ runtime }); - await expect(supervisor.recycle(nonce, 2000)).rejects.toThrow(/provisioning failed/u); - await expect(supervisor.recycle("c".repeat(64), 2000)).resolves.toMatchObject({ ok: true }); + const supervisor = createTrainingSlotSupervisor({ runtimes: [runtime] }); + await expect(supervisor.recycle(0, nonce, 2000)).rejects.toThrow(/provisioning failed/u); + await expect(supervisor.recycle(0, "c".repeat(64), 2000)).resolves.toMatchObject({ ok: true }); }); }); @@ -87,8 +85,8 @@ describe("training slot start-up", () => { it("canaries the slot and publishes the preflight receipt before it hands the slot over", async () => { const { runtime, order } = stubRuntime(); const result = await startTrainingSlot(runtime, nonce); - expect(order).toEqual(["provision", "start", "canaries", "generation", "receipt"]); - expect(result).toEqual({ generation: 1, receipt: "/run/training/slot/preflight.json", canaries: 1 }); + expect(order).toEqual(["canaries", "generation", "receipt"]); + expect(result).toEqual({ generation: 1, receipt: "/run/training/slots/0/preflight.json", canaries: 1 }); }); it("never reaches a receipt when a start-up canary is still reachable", async () => { @@ -97,3 +95,41 @@ describe("training slot start-up", () => { expect(order).not.toContain("receipt"); }); }); + +it("recycles slot 1 without invoking any sibling or shared process lifecycle", async () => { + const slots = Array.from({ length: 4 }, () => stubRuntime()); + await createTrainingSlotSupervisor({ runtimes: slots.map(s => s.runtime) }).recycle(1, nonce, 2000); + expect(slots[1]!.state.leftovers).toEqual([]); + for (const index of [0, 2, 3]) { + expect(slots[index]!.order).toEqual([]); + expect(slots[index]!.state.leftovers).toEqual(["trial-1.json"]); + } +}); + +it("allows different slots to recycle concurrently, while keeping each slot's own order", async () => { + let reached = 0; + let release!: () => void; + const barrier = new Promise(resolve => { release = resolve; }); + const slots = Array.from({ length: 4 }, () => stubRuntime({ reset: async () => { + reached++; if (reached === 4) release(); await barrier; + } })); + const supervisor = createTrainingSlotSupervisor({ runtimes: slots.map(s => s.runtime) }); + await Promise.all(slots.map((_, index) => supervisor.recycle(index, nonce, 2000))); + expect(reached).toBe(4); + for (const slot of slots) expect(slot.order).toEqual(["wipe", "provision", "canaries", "generation", "receipt"]); +}); + +it("refuses an active turn before deleting files or publishing a new receipt", async () => { + const { runtime, state, order } = stubRuntime({ reset: async () => { throw Error("engine broker slot busy"); } }); + await expect(createTrainingSlotSupervisor({ runtimes: [runtime] }).recycle(0, nonce, 2000)).rejects.toThrow(/slot busy/); + expect(state.leftovers).toEqual(["trial-1.json"]); + expect(order).toEqual([]); +}); + +it("rejects every invalid or undeclared slot before contacting the broker", async () => { + const { runtime, order } = stubRuntime(); + for (const slot of [-1, 1, 0.5, NaN, Infinity]) { + await expect(createTrainingSlotSupervisor({ runtimes: [runtime] }).recycle(slot, nonce, 2000)).rejects.toThrow(/declared slot/); + } + expect(order).toEqual([]); +}); diff --git a/src/compiler/training/broker/supervisor.ts b/src/compiler/training/broker/supervisor.ts index 11d4df57..8aac314d 100644 --- a/src/compiler/training/broker/supervisor.ts +++ b/src/compiler/training/broker/supervisor.ts @@ -5,7 +5,7 @@ import path from "node:path"; import { SpawnfileError } from "../../../shared/index.js"; import { DAIMON_ORGANIZATION_UID } from "./paths.js"; -export const TRAINING_SUPERVISOR_PROTOCOL = "spawnfile.training-slot-supervisor.v1" as const; +export const TRAINING_SUPERVISOR_PROTOCOL = "spawnfile.training-slot-supervisor.v2" as const; export const TRAINING_SUPERVISOR_MAX_REQUEST_BYTES = 4_096; const NONCE = /^[a-f0-9]{64}$/u; @@ -17,14 +17,12 @@ const NONCE = /^[a-f0-9]{64}$/u; * of bug a live check finds far too late. */ export interface TrainingSlotRuntime { - /** Waits for no active turn and a settled credential journal; throws a named error on an unrecoverable realm. */ - drain(signal: AbortSignal): Promise; - stop(): Promise; - /** Removes worker home, slot workspace, wake-acceptance, turn store, per-slot ledger and Grok session state. */ + /** Broker refuses active turns and live workers, then resets only this slot clock/turn store. */ + reset(signal: AbortSignal): Promise; + /** Clears this slot while preserving the worker-home/tmp identities attested by sibling turns. */ wipe(): Promise; - /** Replays the audited root provisioning: registrations, service.json, worker home, temp, spill, ledgers. */ + /** Reuses the audited worker provisioning; shared broker files are untouched. */ provision(): Promise; - start(): Promise; /** Worker-uid denial canaries over every deny path; throws when one is still reachable. */ canaries(): Promise<{ path: string; method: "sandboxed-read"; result: "denied" }[]>; /** Writes the slot preflight receipt v2 atomically, readable by the organization uid. */ @@ -50,8 +48,6 @@ export interface TrainingSlotRuntime { * publishes, from the same canaries. */ export const startTrainingSlot = async (runtime: TrainingSlotRuntime, nonce: string): Promise<{ generation: number; receipt: string; canaries: number }> => { - await runtime.provision(); - await runtime.start(); const canaries = await runtime.canaries(); const generation = await runtime.nextGeneration(); const receipt = await runtime.publishReceipt({ generation, nonce, canaries }); @@ -60,7 +56,7 @@ export const startTrainingSlot = async (runtime: TrainingSlotRuntime, nonce: str }; export interface TrainingSlotSupervisorOptions { - runtime: TrainingSlotRuntime; + runtimes: readonly TrainingSlotRuntime[]; /** Only this uid may recycle. Paideia, DSPy and the judges all run as it; every worker uid is refused. */ organizationUid?: number; drainTimeoutMs?: number; @@ -68,48 +64,48 @@ export interface TrainingSlotSupervisorOptions { } export interface TrainingRecycleResult { - ok: true; verb: "recycle"; generation: number; nonce: string; receipt: string; durationMs: number; + ok: true; verb: "recycle"; slot: number; generation: number; nonce: string; receipt: string; durationMs: number; } /** * The single-verb root slot supervisor. * - * One verb, one argument, no path or command ever supplied by the caller: the + * One verb, slot and nonce, no path or command ever supplied by the caller: the * whole point of D4 is that recycling replays the container's own audited * provisioning rather than exposing a root file-system API to the evaluator. - * Recycles are serialized — a second caller waits rather than interleaving a + * Recycles of the same slot are serialized — a second caller waits rather than interleaving a * wipe with a provision. */ export const createTrainingSlotSupervisor = (options: TrainingSlotSupervisorOptions) => { const organizationUid = options.organizationUid ?? DAIMON_ORGANIZATION_UID; const drainTimeoutMs = options.drainTimeoutMs ?? 120_000; const now = options.now ?? Date.now; - let queue: Promise = Promise.resolve(); - const recycle = async (nonce: string, peerUid: number): Promise => { + const queues = new Map>(); + const recycle = async (slot: number, nonce: string, peerUid: number): Promise => { if (peerUid !== organizationUid) throw new SpawnfileError("validation_error", `Grok slot recycle refused for uid ${peerUid}`); if (!NONCE.test(nonce)) throw new SpawnfileError("validation_error", "Grok slot recycle requires a 32-byte hex nonce"); + if (!Number.isInteger(slot) || slot < 0 || slot >= options.runtimes.length) throw new SpawnfileError("validation_error", "Grok slot recycle requires a declared slot index"); + const runtime = options.runtimes[slot]!; const started = now(); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), drainTimeoutMs); try { - await options.runtime.drain(controller.signal); + await runtime.reset(controller.signal); } finally { clearTimeout(timer); } - await options.runtime.stop(); - await options.runtime.wipe(); - await options.runtime.provision(); - await options.runtime.start(); - const canaries = await options.runtime.canaries(); - const generation = await options.runtime.nextGeneration(); - const receipt = await options.runtime.publishReceipt({ generation, nonce, canaries }); + await runtime.wipe(); + await runtime.provision(); + const canaries = await runtime.canaries(); + const generation = await runtime.nextGeneration(); + const receipt = await runtime.publishReceipt({ generation, nonce, canaries }); const durationMs = now() - started; - options.runtime.log(`recycle generation=${generation} canaries=${canaries.length} durationMs=${durationMs}`); - return { ok: true, verb: "recycle", generation, nonce, receipt, durationMs }; + runtime.log(`recycle generation=${generation} canaries=${canaries.length} durationMs=${durationMs}`); + return { ok: true, verb: "recycle", slot, generation, nonce, receipt, durationMs }; }; return { /** Serialized: a recycle already in flight completes before the next one starts. */ - recycle: (nonce: string, peerUid: number): Promise => { - const next = queue.then(() => recycle(nonce, peerUid)); - queue = next.catch(() => undefined); + recycle: (slot: number, nonce: string, peerUid: number): Promise => { + const next = (queues.get(slot) ?? Promise.resolve()).then(() => recycle(slot, nonce, peerUid)); + queues.set(slot, next.catch(() => undefined)); return next; } }; @@ -165,12 +161,12 @@ export const serveTrainingSlotSupervisor = (supervisor: TrainingSlotSupervisor, const newline = buffer.indexOf("\n"); if (newline < 0) return; const line = buffer.slice(0, newline); - let request: { v?: unknown; verb?: unknown; nonce?: unknown }; + let request: { v?: unknown; verb?: unknown; nonce?: unknown; slot?: unknown }; try { request = JSON.parse(line) as typeof request; } catch { answer(failure("request is not JSON")); return; } - if (request.v !== TRAINING_SUPERVISOR_PROTOCOL || request.verb !== "recycle" || typeof request.nonce !== "string" - || Object.keys(request).length !== 3) { answer(failure("unsupported request")); return; } + if (!request || typeof request !== "object" || Array.isArray(request) || request.v !== TRAINING_SUPERVISOR_PROTOCOL || request.verb !== "recycle" || typeof request.nonce !== "string" + || typeof request.slot !== "number" || Object.keys(request).length !== 4) { answer(failure("unsupported request")); return; } if (peerUid === undefined) { answer(failure("peer identity is unavailable")); return; } - supervisor.recycle(request.nonce, peerUid) + supervisor.recycle(request.slot, request.nonce, peerUid) .then((result) => answer(`${JSON.stringify({ v: TRAINING_SUPERVISOR_PROTOCOL, ...result })}\n`)) .catch((error: unknown) => { const message = error instanceof Error ? error.message : "recycle failed"; diff --git a/src/compiler/training/broker/taskClock.test.ts b/src/compiler/training/broker/taskClock.test.ts index 64f02b8f..d0dea97f 100644 --- a/src/compiler/training/broker/taskClock.test.ts +++ b/src/compiler/training/broker/taskClock.test.ts @@ -25,10 +25,21 @@ describe("training task clock preflight", () => { ["test -x", clock.directoryPath], ["test ! -w", clock.directoryPath], ["test -r", clock.preloadPath]]) { expect(probe).toContain(`${verb} '"'"'${target}'"'"'`); } - expect(() => renderTrainingTaskClockAssertions({ ...slot, workerConfigVariant: "unclocked" })).toThrow(/pinned clocked/u); + 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"); diff --git a/src/compiler/training/broker/taskClock.ts b/src/compiler/training/broker/taskClock.ts index bedcd5c6..d9cffcee 100644 --- a/src/compiler/training/broker/taskClock.ts +++ b/src/compiler/training/broker/taskClock.ts @@ -1,32 +1,35 @@ 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 on recycle. */ +/** Reclaim only the clock subtree before the shared program clears the control root at startup. */ export const renderTrainingTaskClockReset = (): string[] => [ - `test ! -L ${quote(clock.directoryPath)}`, - `if [ -d ${quote(clock.directoryPath)} ]; then chown -R 0:0 ${quote(clock.directoryPath)}; chmod -R u+rwX ${quote(clock.directoryPath)}; fi` + `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 = DAIMON_GROK_ENGINE_BROKER.worker.clockedConfigSha256[registration.model][registration.reasoningEffort]; - if (registration.workerConfigVariant !== "clocked" || registration.configSha256 !== pin) { + 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(clock.timestampFilePath)}`, `test ! -w ${quote(clock.timestampFilePath)}`, - `test -x ${quote(clock.directoryPath)}`, `test ! -w ${quote(clock.directoryPath)}`, + `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(clock.directoryPath)} && test -d ${quote(clock.directoryPath)}`, - `test ! -L ${quote(clock.timestampFilePath)} && test -f ${quote(clock.timestampFilePath)}`, - `test "$(stat -c '%u:%g %a' ${quote(clock.directoryPath)})" = "${clock.ownerUid}:${clock.ownerUid} ${clock.directoryMode.toString(8)}"`, - `test "$(stat -c '%u:%g %a %h' ${quote(clock.timestampFilePath)})" = "${clock.ownerUid}:${clock.ownerUid} ${clock.fileMode.toString(8)} 1"`, + `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. diff --git a/src/compiler/training/container/AGENTS.md b/src/compiler/training/container/AGENTS.md index d26458e1..94833759 100644 --- a/src/compiler/training/container/AGENTS.md +++ b/src/compiler/training/container/AGENTS.md @@ -2,7 +2,9 @@ - One immutable image runs the whole experiment; the host only prepares declared mounts and supervises Docker. - `contract.ts` validates explicit launch configuration. `prepare.ts` maps canonical context and CLI paths into declared mounts. +- Brokered launches leave concurrency absent so comparisons stay serial; optimization defaults to the descriptor count. Explicit lower concurrency is allowed; oversubscription fails before Docker. Set `PAIDEIA_MODEL_REQUEST_CAP=broker` so uncapped inference routes fail closed. - `process.ts` owns bounded Docker subprocess transport. `launch.ts` owns exact container identity, streaming, receipt verification and cleanup. +- `grokWriteback.ts` exports the stopped, ownership-verified broker's credential and journal privately before removal; auth owns expiry/lineage checks and atomic profile replacement. Failure and cancellation take the same path. The container never writes a host auth mount. - Never mount a Docker socket, host home, arbitrary environment, or host executable. Auth bindings are explicit read-only leaf files. - Dry-run stays in the existing host estimator. Actual training must never fall back to host execution. - Docker bind mounts currently require an explicitly selected local Unix-socket context. Remote daemon staging is unsupported. diff --git a/src/compiler/training/container/contract.ts b/src/compiler/training/container/contract.ts index 29860540..048be678 100644 --- a/src/compiler/training/container/contract.ts +++ b/src/compiler/training/container/contract.ts @@ -10,26 +10,28 @@ const brokerLaunchSchema = z.object({ realmVolume: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/u), /** Host leaf of the dedicated training Grok login. Never the desktop `~/.grok/auth.json`. */ bootstrap: hostPath, - /** `spawnfile.training-broker.v1`, written beside the launch config and bound read-only into the container. */ + /** `spawnfile.training-broker.v2`, written beside the launch config and bound read-only into the container. */ declaration: hostPath }).strict(); export const trainingContainerConfigSchema = z.object({ - version: z.enum(["spawnfile.training-container.v1", "spawnfile.training-container.v3"]), + version: z.enum(["spawnfile.training-container.v1", "spawnfile.training-container.v3", "spawnfile.training-container.v4"]), + slots: z.number().int().min(1).max(8).default(4), dockerContext: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/u), inputs: z.array(z.object({ source: hostPath, destination: inputPath }).strict()).min(1).max(64), output: z.object({ source: hostPath, destination: z.literal("/run/training/output") }).strict(), auth: z.array(z.object({ source: hostPath, provider: z.enum(["codex", "claude"]) }).strict()).max(2), - /** Present only on `spawnfile.training-container.v3`: the brokered Grok slot this container runs as root. */ + /** Present only on `spawnfile.training-container.v4`: the brokered Grok slot this container runs as root. */ broker: brokerLaunchSchema.optional() }).strict().superRefine((value, context) => { + if (value.version === "spawnfile.training-container.v3") context.addIssue({ code: "custom", message: "Training-container v3 is unsupported; migrate to v4 with slots: 1" }); const destinations = value.inputs.map((entry) => entry.destination); if (destinations.some((entry, index) => destinations.some((other, otherIndex) => otherIndex !== index && (entry === other || entry.startsWith(`${other}/`))))) { context.addIssue({ code: "custom", message: "Input destinations must not overlap" }); } if (new Set(value.auth.map((entry) => entry.provider)).size !== value.auth.length) context.addIssue({ code: "custom", message: "Duplicate auth provider" }); - if ((value.version === "spawnfile.training-container.v3") !== (value.broker !== undefined)) { - context.addIssue({ code: "custom", message: "Only spawnfile.training-container.v3 declares a broker slot, and it always does" }); + if ((value.version === "spawnfile.training-container.v4") !== (value.broker !== undefined)) { + context.addIssue({ code: "custom", message: "Only spawnfile.training-container.v4 declares broker slots; migrate v3 by setting version to v4 and slots to 1" }); } }); export type TrainingContainerConfig = z.infer; diff --git a/src/compiler/training/container/grokWriteback.test.ts b/src/compiler/training/container/grokWriteback.test.ts new file mode 100644 index 00000000..3a377ce9 --- /dev/null +++ b/src/compiler/training/container/grokWriteback.test.ts @@ -0,0 +1,129 @@ +import { createHash } from "node:crypto"; +import * as fs from "node:fs/promises"; +import { readFile, rm, stat, writeFile } from "node:fs/promises"; +import { DatabaseSync } from "node:sqlite"; +import path from "node:path"; +import { afterEach, expect, it, vi } from "vitest"; +import { fixture, dockerFixture, image } from "./fixtures.test-helper.js"; +import { parseTrainingBrokerDeclaration } from "../broker/declaration.js"; +import { launchTrainingContainer } from "./launch.js"; + +// Interpose at publication only; filesystem operations and SQLite locks stay real. +vi.mock("node:fs/promises", async (original) => ({ ...await original() })); +const roots: string[] = []; +afterEach(async () => { vi.restoreAllMocks(); for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }); }); +const credential = (day: number) => JSON.stringify({ "https://auth.x.ai::fixture": { key: "synthetic", refresh_token: "synthetic", expires_at: `2030-01-${String(day).padStart(2, "0")}T00:00:00Z` } }); +const digest = (value: string) => createHash("sha256").update(value).digest("hex"); +const setup = async (sharedProfile?: string) => { + const f = await fixture(); roots.push(f.root); + const profile = sharedProfile ?? path.join(f.root, "grok-profile"), declaration = path.join(f.root, "broker.json"); + if (!sharedProfile) await writeFile(profile, credential(1), { mode: 0o600 }); + await writeFile(declaration, JSON.stringify(parseTrainingBrokerDeclaration({ + version: "spawnfile.training-broker.v2", slots: 4, engine: "grok", agentId: "agent:a", + model: "grok-4.6", reasoningEffort: "low", architecture: "arm64", bootstrap: profile, + organizationUid: 2000, seccompProfileSha256: "a".repeat(64), + limits: { maxRequests: 10, maxTokens: 10000, timeoutMs: 60000 } + }))); + await writeFile(f.configPath, JSON.stringify({ ...f.config, version: "spawnfile.training-container.v4", slots: 4, + broker: { engine: "grok", bootstrap: profile, declaration, realmVolume: "fixture-realm" } })); + return { ...f, profile }; +}; + +it.each(["success", "failure", "cancel"])("writes back after %s and the next container starts from renewed auth", async (outcome) => { + const f = await setup(), controller = new AbortController(); let running = false; + const streams = { stdout: vi.fn(), stderr: vi.fn() }; + const docker = dockerFixture(async (args) => { + if (args[2] === "start") { + if (outcome === "cancel") { running = true; controller.abort(); throw Error("cancelled"); } + if (outcome === "failure") return { code: 2, stdout: "", stderr: "" }; + } + if (args[4] === "{{json .State}}") return { code: 0, stdout: JSON.stringify({ Running: running, ExitCode: outcome === "failure" ? 2 : 0 }), stderr: "" }; + if (args[2] === "stop") { running = false; return { code: 0, stdout: "", stderr: "" }; } + if (args[2] === "cp") { + expect(running).toBe(false); + const journal = args[3]!.endsWith("credential-journal.json"); + await writeFile(args[4]!, journal ? JSON.stringify({ version: "noopolis.daimon.broker-credential-journal.v1", state: "promoted", + generation: 1, sourceDigest: digest(credential(1)), promotedDigest: digest(credential(2)) }) : credential(2), { mode: 0o600 }); + return { code: 0, stdout: "", stderr: "" }; + } + if (args[2] === "rm") expect(await readFile(f.profile, "utf8")).toBe(credential(2)); + return undefined; + }); + expect(await launchTrainingContainer({ ...f, image, timeoutMs: 3000, signal: controller.signal, streams, process: docker.process })) + .toBe(outcome === "cancel" ? 130 : outcome === "failure" ? 2 : 0); + expect(await readFile(f.profile, "utf8")).toBe(credential(2)); + expect(JSON.stringify(streams.stdout.mock.calls)).not.toContain("synthetic"); + expect(JSON.stringify(streams.stderr.mock.calls)).not.toContain("synthetic"); + // A separate launch resolves the same profile pathname after atomic replacement. + const next = dockerFixture(async (args, options) => { + if (args[2] === "create") expect(await readFile(f.profile, "utf8")).toBe(credential(2)); + if (args[2] === "cp") return docker.process(args, options); + return undefined; + }); + expect(await launchTrainingContainer({ ...f, image, timeoutMs: 3000, streams, process: next.process })).toBe(0); +}); + +it.each([2, 3])("serializes concurrent cleanup with expiry %i first, without caller retries", async (firstDay) => { + const first = await setup(), second = await setup(first.profile), secondDay = firstDay === 2 ? 3 : 2; + const deferred = () => { + let resolve!: () => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; + }; + const publishing = deferred(), proceed = deferred(), contended = deferred(); + const rename = fs.rename, exec = DatabaseSync.prototype.exec; + let active = 0, maximum = 0; + const published: string[] = [], scratch = new Set(); + vi.spyOn(fs, "rename").mockImplementation(async (from, to) => { + expect(to).toBe(first.profile); + maximum = Math.max(maximum, ++active); + try { + published.push(await readFile(from, "utf8")); + if (published.length === 1) { publishing.resolve(); await proceed.promise; } + await rename(from, to); + } finally { active--; } + }); + vi.spyOn(DatabaseSync.prototype, "exec").mockImplementation(function (this: DatabaseSync, sql) { + try { return exec.call(this, sql); } + catch (error) { if ((error as { errcode?: number }).errcode === 5) contended.resolve(); throw error; } + }); + const docker = (day: number) => dockerFixture(async (args) => { + if (args[2] === "cp") { + scratch.add(path.dirname(args[4]!)); + await writeFile(args[4]!, args[3]!.endsWith("credential-journal.json") ? JSON.stringify({ + version: "noopolis.daimon.broker-credential-journal.v1", state: "promoted", generation: 1, + sourceDigest: digest(credential(1)), promotedDigest: digest(credential(day)) + }) : credential(day), { mode: 0o600 }); + return { code: 0, stdout: "", stderr: "" }; + } + return undefined; + }); + const a = docker(firstDay), b = docker(secondDay), streams = { stdout() {}, stderr() {} }; + const pendingFirst = launchTrainingContainer({ ...first, image, timeoutMs: 3000, streams, process: a.process }); + let pendingSecond: Promise | undefined; + try { + await Promise.race([publishing.promise, pendingFirst.then(() => { throw Error("First cleanup skipped publication"); })]); + pendingSecond = launchTrainingContainer({ ...second, image, timeoutMs: 3000, streams, process: b.process }); + const results = Promise.allSettled([pendingFirst, pendingSecond]); + // Release only after SQLite positively proves the second cleanup contended. + await Promise.race([contended.promise, pendingSecond.then(() => { throw Error("Second cleanup bypassed contention"); })]); + expect(await readFile(first.profile, "utf8")).toBe(credential(1)); + proceed.resolve(); + expect(await results).toEqual([{ status: "fulfilled", value: 0 }, { status: "fulfilled", value: 0 }]); + expect(maximum).toBe(1); + expect(published).toEqual(firstDay === 2 ? [credential(2), credential(3)] : [credential(3)]); + expect(await readFile(first.profile, "utf8")).toBe(credential(3)); + for (const value of [a, b]) expect(value.calls.filter(args => args[2] === "rm")).toHaveLength(1); + expect(scratch.size).toBe(2); + for (const root of scratch) await expect(stat(root)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { proceed.resolve(); await Promise.allSettled([pendingFirst, ...(pendingSecond ? [pendingSecond] : [])]); } +}); + +it("fails visibly on export failure, preserves the profile and still removes the owned container", async () => { + const f = await setup(); + const docker = dockerFixture(async (args) => args[2] === "cp" ? { code: 1, stdout: "", stderr: "not reported" } : undefined); + await expect(launchTrainingContainer({ ...f, image, timeoutMs: 3000, streams: { stdout() {}, stderr() {} }, process: docker.process })) + .rejects.toThrow("Cannot export training Grok credential"); + expect(await readFile(f.profile, "utf8")).toBe(credential(1)); + expect(docker.calls.some((args) => args[2] === "rm")).toBe(true); +}); diff --git a/src/compiler/training/container/grokWriteback.ts b/src/compiler/training/container/grokWriteback.ts new file mode 100644 index 00000000..4be37046 --- /dev/null +++ b/src/compiler/training/container/grokWriteback.ts @@ -0,0 +1,25 @@ +import path from "node:path"; +import { writeBackTrainingGrokCredential } from "../../../auth/index.js"; +import { DAIMON_GROK_ENGINE_BROKER } from "../../../runtime/daimon/contractManifest.js"; + +type DockerCall = (args: string[], cleanup: boolean) => Promise<{ code: number; stdout: string; stderr: string }>; + +/** Called only for an ownership-verified container, before removal, including failure/cancellation. */ +export const persistTrainingGrokCredential = async (id: string, profile: string, scratch: string, call: DockerCall): Promise => { + const state = await call(["inspect", "--format", "{{json .State}}", id], true); + if (state.code !== 0) throw Error("Cannot verify training credential writer state"); + if (JSON.parse(state.stdout).Running === true) { + const stopped = await call(["stop", "--time", "5", id], true); + if (stopped.code !== 0) throw Error("Cannot stop training credential writer"); + } + const terminal = await call(["inspect", "--format", "{{json .State}}", id], true); + if (terminal.code !== 0 || JSON.parse(terminal.stdout).Running !== false) throw Error("Training credential writer is still active"); + const realm = DAIMON_GROK_ENGINE_BROKER.credentialHomePath; + const candidate = path.join(scratch, "grok-auth.json"), journal = path.join(scratch, "grok-journal.json"); + // Docker writes directly into a host-private directory: no credential bytes in stdout, logs or arguments. + const copied = await call(["cp", `${id}:${realm}/auth.json`, candidate], true); + if (copied.code !== 0) throw Error("Cannot export training Grok credential"); + const journalCopy = await call(["cp", `${id}:${realm}/.daimon-broker/credential-journal.json`, journal], true); + // A realm that never rotated has no journal. The auth owner accepts only unchanged bytes in that case. + await writeBackTrainingGrokCredential(profile, candidate, journalCopy.code === 0 ? journal : `${journal}.absent`); +}; diff --git a/src/compiler/training/container/launch.ts b/src/compiler/training/container/launch.ts index db3c1234..536308b4 100644 --- a/src/compiler/training/container/launch.ts +++ b/src/compiler/training/container/launch.ts @@ -2,8 +2,10 @@ import { randomUUID } from "node:crypto"; import { chmod, mkdtemp, readFile, realpath, rm, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { DAIMON_CODEX_NATIVE_SANDBOX_DOCKER_SECURITY_OPTS } from "../../../shared/index.js"; +import { parseTrainingBrokerDeclaration } from "../broker/declaration.js"; +import { trainingSlotsDocument, TRAINING_SLOTS_FILE } from "../broker/paths.js"; import { assertNotDesktopGrokAuth } from "../broker/entrypoint.js"; -import { TRAINING_BROKER_ENTRYPOINT, trainingBrokerMounts, trainingBrokerSecurityArgs, trainingBrokerTmpfsTargets } from "./security.js"; +import { TRAINING_BROKER_ENTRYPOINT, trainingBrokerMounts, trainingBrokerSecurityArgs, trainingBrokerTmpfsTargets, trainingRunRequestMount } from "./security.js"; import { parseDetachedContainerInspect } from "../../runProjectDocker.js"; import type { TrainingContext } from "../contract.js"; import { trainingImageSchema } from "./contract.js"; @@ -12,6 +14,7 @@ import { runTrainingDocker, type TrainingDockerProcess } from "./process.js"; import { repairEnvelopeSchema } from "../repair/contract.js"; import { hashJson } from "../preparation/files.js"; import { parseTrainingMappedPreparation } from "../preparation/contract.js"; +import { persistTrainingGrokCredential } from "./grokWriteback.js"; export interface LaunchTrainingContainerOptions { image: string; configPath: string; context: TrainingContext; args: readonly string[]; @@ -31,7 +34,11 @@ export const launchTrainingContainer = async (options: LaunchTrainingContainerOp if (Buffer.byteLength(configBytes) > 1024 * 1024) throw Error("Training launch config exceeds 1 MiB"); const prepared = await prepareTrainingContainer(JSON.parse(configBytes), options.context, options.args); // D2: the training Grok login is a dedicated one. Refuse the developer's desktop leaf before Docker ever sees it. - if (prepared.config.broker) assertNotDesktopGrokAuth(prepared.config.broker.bootstrap); + if (prepared.config.broker) { + assertNotDesktopGrokAuth(prepared.config.broker.bootstrap); + const declaration = parseTrainingBrokerDeclaration(JSON.parse(await readFile(prepared.config.broker.declaration, "utf8"))); + if (declaration.slots !== prepared.config.slots) throw Error("Training launch and broker declaration slots differ"); + } if (options.preparationPath) { if (await realpath(options.preparationPath) !== options.preparationPath) throw Error("Preparation receipt path must be canonical"); parseTrainingMappedPreparation(JSON.parse(await readFile(options.preparationPath, "utf8"))); @@ -60,7 +67,7 @@ export const launchTrainingContainer = async (options: LaunchTrainingContainerOp }); }; const name = `spawnfile-training-${randomUUID()}`; const labels = { "com.spawnfile.training.owner": name }; - let privateRoot: string | undefined, containerId: string | undefined, creationAttempted = false; + let privateRoot: string | undefined, containerId: string | undefined, creationAttempted = false, startAttempted = false; let expectedImage: string | undefined, lastLine = "", closureVerified = false; try { const endpoint = await execute(["context", "inspect", prepared.config.dockerContext, "--format", "{{json .Endpoints.docker.Host}}"], { timeoutMs: 10_000, signal: controller.signal }); @@ -79,11 +86,17 @@ export const launchTrainingContainer = async (options: LaunchTrainingContainerOp ...options.repairPath ? [`type=bind,src=${options.repairPath},dst=/run/paideia/repair.json,readonly`] : [], ...prepared.config.auth.map((entry) => `type=bind,src=${entry.source},dst=/run/paideia-auth/${entry.provider},readonly`)]; const broker = prepared.config.broker; + if (broker) { + mounts.push(trainingRunRequestMount(prepared.config.output.source, prepared.args)); + const slotsFile = path.join(privateRoot, "slots.json"); + await writeFile(slotsFile, `${JSON.stringify(trainingSlotsDocument(prepared.config.slots))}\n`, { mode: 0o444, flag: "wx" }); + mounts.push(`type=bind,src=${slotsFile},dst=${TRAINING_SLOTS_FILE},readonly`); + } // A brokered Grok slot runs the launcher, the broker and the model's own worker uid inside this container, so it // starts as root with the production Daimon capability set instead of the host user with no capabilities at all. const privilege = broker ? [...await trainingBrokerSecurityArgs(privateRoot), "--pids-limit", "2048", - ...trainingBrokerTmpfsTargets().flatMap((entry) => ["--tmpfs", `${entry.path}:rw,nosuid,nodev,size=${entry.size},mode=${entry.mode}`])] + ...trainingBrokerTmpfsTargets(prepared.config.slots).flatMap((entry) => ["--tmpfs", `${entry.path}:rw,nosuid,nodev,size=${entry.size},mode=${entry.mode}`])] : ["--user", `${uid}:${gid}`, "--cap-drop", "ALL", "--security-opt", "no-new-privileges", ...DAIMON_CODEX_NATIVE_SANDBOX_DOCKER_SECURITY_OPTS, "--pids-limit", "512", "--tmpfs", `/tmp:rw,nosuid,nodev,size=1g,uid=${uid},gid=${gid},mode=1777`, "--tmpfs", `/work:rw,nosuid,nodev,size=4g,uid=${uid},gid=${gid},mode=700`, @@ -91,6 +104,7 @@ export const launchTrainingContainer = async (options: LaunchTrainingContainerOp const args = ["create", "--name", name, "--label", `com.spawnfile.training.owner=${name}`, "--init", "--read-only", ...privilege, "--env", "HOME=/home/training", "--workdir", "/work", "--entrypoint", broker ? TRAINING_BROKER_ENTRYPOINT : "/opt/training/bin/train", + ...(broker ? ["--env", "PAIDEIA_MODEL_REQUEST_CAP=broker"] : []), ...[...mounts, ...(broker ? trainingBrokerMounts(broker) : [])].flatMap((mount) => ["--mount", mount]), ...(prepared.viewerPort === undefined ? [] : ["--publish", `127.0.0.1:${prepared.viewerPort}:${prepared.viewerPort}`]), expectedImage, "train", "--spawnfile-context", "/run/paideia/context.json", ...prepared.args]; @@ -106,6 +120,7 @@ export const launchTrainingContainer = async (options: LaunchTrainingContainerOp const identity = await call(["inspect", "--format", inspectFormat, containerId]); if (identity.code !== 0 || parseDetachedContainerInspect(identity.stdout, containerId, labels, name).imageId !== expectedImage) throw Error("Training container identity mismatch"); if (controller.signal.aborted) throw Error("Container operation cancelled"); + startAttempted = true; const result = await execute([...prefix, "start", "--attach", containerId], { timeoutMs: Math.max(1, deadline - Date.now()), signal: controller.signal, stdout: (line) => { if (line.trim()) lastLine = line; options.streams.stdout(line); }, stderr: options.streams.stderr @@ -133,10 +148,16 @@ export const launchTrainingContainer = async (options: LaunchTrainingContainerOp if (found.code === 0) { const id: unknown = JSON.parse(found.stdout.split("\n")[0]!); if (typeof id !== "string" || parseDetachedContainerInspect(found.stdout, id, labels, name).imageId !== expectedImage || (containerId && /^[a-f0-9]{64}$/u.test(containerId) && id !== containerId)) throw Error("Refusing cleanup of unverified training container"); - const removed = await call(["rm", "--force", id], true); - const remaining = await call(["container", "ls", "--all", "--no-trunc", "--filter", `id=${id}`, "--format", "{{.ID}}"], true); - if (removed.code !== 0 || remaining.code !== 0 || remaining.stdout.trim()) throw Error("Training container cleanup is unverified"); - closureVerified = true; + try { + if (startAttempted && prepared.config.broker && privateRoot) { + await persistTrainingGrokCredential(id, prepared.config.broker.bootstrap, privateRoot, call); + } + } finally { + const removed = await call(["rm", "--force", id], true); + const remaining = await call(["container", "ls", "--all", "--no-trunc", "--filter", `id=${id}`, "--format", "{{.ID}}"], true); + if (removed.code !== 0 || remaining.code !== 0 || remaining.stdout.trim()) throw Error("Training container cleanup is unverified"); + closureVerified = true; + } } else { const remaining = await call(["container", "ls", "--all", "--filter", `name=^/${name}$`, "--format", "{{.ID}}"], true); if (remaining.code !== 0 || remaining.stdout.trim()) throw Error("Training container closure is unknown"); diff --git a/src/compiler/training/container/prepare.ts b/src/compiler/training/container/prepare.ts index fd042044..65a239bb 100644 --- a/src/compiler/training/container/prepare.ts +++ b/src/compiler/training/container/prepare.ts @@ -62,5 +62,17 @@ export const prepareTrainingContainer = async (raw: unknown, context: TrainingCo mappedArgs[index] = String(port); } } + if (config.broker) { + const flags = mappedArgs.filter(arg => arg === "--concurrency" || arg.startsWith("--concurrency=")); + if (flags.length > 1) throw Error("Training accepts one --concurrency value"); + const flag = flags[0]; + if (flag !== undefined) { + const value = flag === "--concurrency" ? mappedArgs[mappedArgs.indexOf(flag) + 1] : flag.slice("--concurrency=".length); + if (!value || !/^[1-8]$/u.test(value) || Number(value) > config.slots) { + throw Error("Training concurrency must fit the provisioned slots"); + } + if (flag !== "--concurrency") mappedArgs.splice(mappedArgs.indexOf(flag), 1, "--concurrency", value); + } + } return { config, context: mapped, args: mappedArgs, ...(viewerPort === undefined ? {} : { viewerPort }) }; }; diff --git a/src/compiler/training/container/security.ts b/src/compiler/training/container/security.ts index 47dad02c..22f288cb 100644 --- a/src/compiler/training/container/security.ts +++ b/src/compiler/training/container/security.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { DAIMON_DOCKER_RUNTIME_SECURITY_ARGS, materializeDaimonGrokSeccompProfile @@ -10,30 +11,41 @@ import { TRAINING_GRANT_HOME_ROOT, TRAINING_INFERENCE_DIRECTORY, TRAINING_PAIDEIA_ROOT, - TRAINING_SLOT_ROOT, + trainingSlotDescriptors, + TRAINING_TURN_STORE_ROOT, + TRAINING_RUN_REQUEST_STORE, TRAINING_SUPERVISOR_DIRECTORY, TRAINING_WORKER_ROOT } from "../broker/paths.js"; export const TRAINING_BROKER_ENTRYPOINT = "/opt/training/bin/train-broker"; +/** Same output directory is the same resumable run; different outputs never share a counter. */ +export const trainingRunRequestMount = (outputSource: string, args: readonly string[]): string => { + const index = args.indexOf("--out"), output = args[index + 1]; + if (index < 0 || !output || args.lastIndexOf("--out") !== index) throw Error("Brokered training requires one --out directory"); + const identity = createHash("sha256").update(JSON.stringify([outputSource, output])).digest("hex"); + return `type=volume,src=spawnfile-training-budget-${identity},dst=${TRAINING_RUN_REQUEST_STORE}`; +}; + /** - * Writable state of a broker-capable training container, all on tmpfs. + * Disposable state of a broker-capable training container, all on tmpfs. * * The image root stays read-only and nothing here is a host bind, so a trial's * worker home, workspace, turn store, wake-acceptance store and ledgers are * on a filesystem that enforces unix ownership — which is what lets the slot * supervisor's worker-uid canaries mean anything (P0 §5: a host bind under * Docker Desktop or Colima silently ignores `chown`). The Grok realm is the - * one durable mount, and it is a named volume, not a bind. + * credential mount. The run request counter has its own persistent named volume. */ -export const trainingBrokerTmpfsTargets = (): readonly { path: string; size: string; mode: string }[] => [ +export const trainingBrokerTmpfsTargets = (slots = 4): readonly { path: string; size: string; mode: string }[] => [ { path: "/tmp", size: "1g", mode: "1777" }, { path: "/var/tmp", size: "256m", mode: "1777" }, { path: "/work", size: "4g", mode: "0755" }, { path: "/home/training", size: "1g", mode: "0755" }, { path: TRAINING_PAIDEIA_ROOT, size: "64m", mode: "0755" }, - { path: TRAINING_SLOT_ROOT, size: "4g", mode: "0755" }, + ...trainingSlotDescriptors(slots).map(p => ({ path: p.root, size: "4g", mode: "0711" })), + { path: TRAINING_TURN_STORE_ROOT, size: "256m", mode: "0700" }, { path: TRAINING_GRANT_HOME_ROOT, size: "64m", mode: "0755" }, { path: TRAINING_INFERENCE_DIRECTORY, size: "64m", mode: "0755" }, { path: TRAINING_SUPERVISOR_DIRECTORY, size: "16m", mode: "0755" }, diff --git a/src/compiler/training/container/slots.test.ts b/src/compiler/training/container/slots.test.ts new file mode 100644 index 00000000..c3ff8f50 --- /dev/null +++ b/src/compiler/training/container/slots.test.ts @@ -0,0 +1,107 @@ +import { chmod, copyFile, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { expect, it, vi } from "vitest"; +import { trainingContainerConfigSchema } from "./contract.js"; +import { launchTrainingContainer } from "./launch.js"; +import { prepareTrainingContainer } from "./prepare.js"; +import { trainingRunRequestMount, trainingBrokerTmpfsTargets } from "./security.js"; +import { fixture, dockerFixture, image } from "./fixtures.test-helper.js"; +import { trainingPreparationSchema } from "../preparation/contract.js"; +import { parseTrainingBrokerDeclaration } from "../broker/declaration.js"; + +const brokerDeclaration = (slots = 4) => ({ version: "spawnfile.training-broker.v2", engine: "grok", agentId: "agent:a", model: "grok-4.6", + reasoningEffort: "low", architecture: "arm64", limits: { maxRequests: 4, maxTokens: 100, timeoutMs: 1000 }, slots, + bootstrap: "/var/lib/spawnfile/daimon/grok-bootstrap-auth", organizationUid: 2000, seccompProfileSha256: "a".repeat(64) }); +const configs = () => { + const base = { version: "spawnfile.training-container.v4", dockerContext: "local", auth: [], + inputs: [{ source: "/host/project", destination: "/run/training/inputs/project" }], + output: { source: "/host/output", destination: "/run/training/output" } }; + const broker = { engine: "grok", realmVolume: "realm", bootstrap: "/host/auth", declaration: "/host/broker.json" }; + return { launch: { ...base, broker }, preparation: { ...base, + inputs: [{ id: "project", ...base.inputs[0] }], image: { ref: image }, integration: { settings: { input: "project", path: "." } }, + broker: { ...brokerDeclaration(), version: undefined, slots: undefined, organizationUid: undefined, seccompProfileSha256: undefined, realmVolume: "realm" } + } }; +}; +it("defaults v4 to four slots, accepts 1..8, and explicitly refuses legacy v3", () => { + const { launch, preparation } = configs(); + const { version: _version, slots: _slots, organizationUid: _uid, seccompProfileSha256: _sha, ...broker } = preparation.broker; + const authoring = { ...preparation, broker }; + expect(trainingContainerConfigSchema.parse(launch).slots).toBe(4); + expect(trainingPreparationSchema.parse(authoring).slots).toBe(4); + expect(parseTrainingBrokerDeclaration({ ...brokerDeclaration(), slots: undefined }).slots).toBe(4); + for (const slots of [0, 9, -1, 1.5, "4", null]) { + expect(() => trainingContainerConfigSchema.parse({ ...launch, slots })).toThrow(); + expect(() => trainingPreparationSchema.parse({ ...authoring, slots })).toThrow(); + expect(() => parseTrainingBrokerDeclaration({ ...brokerDeclaration(), slots })).toThrow(); + } + for (const slots of [1, 8]) { + expect(trainingContainerConfigSchema.parse({ ...launch, slots }).slots).toBe(slots); + expect(trainingPreparationSchema.parse({ ...authoring, slots }).slots).toBe(slots); + } + for (const [schema, config] of [[trainingContainerConfigSchema, launch], [trainingPreparationSchema, authoring]] as const) + expect(() => schema.parse({ ...config, version: "spawnfile.training-container.v3" })).toThrow(/migrate/); +}); + +it("mounts four slot roots and the exact read-only descriptor document during launch", async () => { + const f = await fixture(); + await chmod(path.join(f.root, "auth-leaf"), 0o600); + const declaration = path.join(f.root, "broker.json"); + await writeFile(declaration, JSON.stringify(brokerDeclaration())); + await writeFile(f.configPath, JSON.stringify({ ...f.config, version: "spawnfile.training-container.v4", slots: 4, + broker: { engine: "grok", realmVolume: "training-realm", bootstrap: path.join(f.root, "auth-leaf"), declaration } })); + let seen = false; + const docker = dockerFixture(async args => { + // No refresh in this mount test: export the unchanged bootstrap after stop. + if (args[2] === "cp") { + if (args[3]!.endsWith("credential-journal.json")) return { code: 1, stdout: "", stderr: "" }; + await copyFile(path.join(f.root, "auth-leaf"), args[4]!); + return { code: 0, stdout: "", stderr: "" }; + } + if (args[2] !== "create") return undefined; + const mount = args.find(a => a.includes("dst=/run/paideia/slots.json"))!; + expect(args).toContain(trainingRunRequestMount(f.output, ["--out", "/run/training/output"])); + expect(mount).toContain(",readonly"); + const source = /src=([^,]+)/.exec(mount)![1]!; + const document = JSON.parse(await readFile(source, "utf8")); + expect(args).not.toContain("--concurrency"); + expect(document.schema).toBe("spawnfile.training-slots.v1"); + expect(document.slots.map((p: {index: number}) => p.index)).toEqual([0, 1, 2, 3]); + for (let i = 0; i < 4; i++) expect(args).toContain(`/run/training/slots/${i}:rw,nosuid,nodev,size=4g,mode=0711`); + seen = true; + return undefined; + }); + const status = await launchTrainingContainer({ ...f, image, timeoutMs: 5000, process: docker.process, streams: { stdout: vi.fn(), stderr: vi.fn() } }); + expect(status).toBe(0); expect(seen).toBe(true); + expect(docker.calls.find(args => args[2] === "create")).toContain("PAIDEIA_MODEL_REQUEST_CAP=broker"); + await writeFile(declaration, JSON.stringify(brokerDeclaration(1))); + await expect(launchTrainingContainer({ ...f, image, timeoutMs: 5000, process: docker.process, + streams: { stdout: vi.fn(), stderr: vi.fn() } })).rejects.toThrow(/slots differ/); +}); + +it("retains one broker-only budget volume across container replacement and separates run directories", () => { + const mount = trainingRunRequestMount("/host/output", ["--out", "/run/training/output/run-a"]); + expect(trainingRunRequestMount("/host/output", ["--out", "/run/training/output/run-a", "--resume"])).toBe(mount); + expect(trainingRunRequestMount("/host/output", ["--out", "/run/training/output/run-b"])).not.toBe(mount); + expect(trainingRunRequestMount("/other/output", ["--out", "/run/training/output/run-a"])).not.toBe(mount); + expect(mount).toMatch(/^type=volume,src=spawnfile-training-budget-[a-f0-9]{64},dst=\/run\/training\/request-budget$/u); + expect(trainingBrokerTmpfsTargets().some(entry => entry.path === "/run/training/request-budget")).toBe(false); + for (const args of [[], ["--out"], ["--out", "/one", "--out", "/two"]]) { + expect(() => trainingRunRequestMount("/host/output", args)).toThrow(/one --out/); + } +}); + +it("leaves concurrency absent for serial comparisons and validates explicit optimization controls before Docker", async () => { + const f = await fixture(); + for (const slots of [1, 2, 4, 8]) { + const config = { ...f.config, version: "spawnfile.training-container.v4", slots, + broker: { engine: "grok", realmVolume: "training-realm", bootstrap: path.join(f.root, "auth-leaf"), declaration: path.join(f.root, "broker.json") } }; + expect((await prepareTrainingContainer(config, f.context, f.args)).args).not.toContain("--concurrency"); + for (const override of [["--concurrency", "1"], ["--concurrency=1"]]) { + expect((await prepareTrainingContainer(config, f.context, [...f.args, ...override])).args.slice(-2)).toEqual(["--concurrency", "1"]); + } + for (const override of [["--concurrency", "0"], ["--concurrency", String(slots + 1)], ["--concurrency"], + ["--concurrency", "1", "--concurrency=1"]]) { + await expect(prepareTrainingContainer(config, f.context, [...f.args, ...override])).rejects.toThrow(/concurrency/); + } + } +}); diff --git a/src/compiler/training/index.ts b/src/compiler/training/index.ts index e5d9f970..32d329ed 100644 --- a/src/compiler/training/index.ts +++ b/src/compiler/training/index.ts @@ -5,3 +5,5 @@ export * from "./rewind/index.js"; export type { ResolvedTraining, ResolvedTrainingGrader } from "./resolution.js"; export { parseTrainingMappedPreparation } from "./preparation/contract.js"; export type { TrainingMappedPreparation } from "./preparation/contract.js"; +export { TRAINING_SLOTS_FILE, TRAINING_SLOTS_VERSION, trainingSlotPaths, trainingSlotDescriptors, + trainingSlotsDocument, type TrainingSlotsDocument, type TrainingSlotPaths, type TrainingSlotDescriptor } from "./broker/paths.js"; diff --git a/src/compiler/training/preparation/broker.test.ts b/src/compiler/training/preparation/broker.test.ts new file mode 100644 index 00000000..abd09761 --- /dev/null +++ b/src/compiler/training/preparation/broker.test.ts @@ -0,0 +1,18 @@ +import { mkdtemp, readFile, writeFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { expect, it } from "vitest"; +import { prepareTrainingBroker } from "./broker.js"; + +it("persists the requested slot count in the strict v2 broker declaration", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "training-broker-v2-")); + try { + await writeFile(path.join(root, "auth.json"), "fixture", { mode: 0o600 }); + const broker = { maxRunRequests: 17, engine: "grok" as const, agentId: "agent:a", model: "grok-4.6" as const, reasoningEffort: "low" as const, + architecture: "arm64" as const, limits: { maxRequests: 4, maxTokens: 100, timeoutMs: 1000 }, realmVolume: "training", bootstrap: "auth.json" }; + const prepared = await prepareTrainingBroker(broker, root, root, true, 3); + expect(JSON.parse(await readFile(prepared.declarationPath, "utf8"))).toMatchObject({ version: "spawnfile.training-broker.v2", slots: 3, maxRunRequests: 17 }); + expect(prepared.launch.bootstrap).toBe(path.join(root, "auth.json")); + await expect(prepareTrainingBroker(broker, root, root, false, 9)).rejects.toThrow(); + } finally { await rm(root, { recursive: true, force: true }); } +}); diff --git a/src/compiler/training/preparation/broker.ts b/src/compiler/training/preparation/broker.ts index 7be88834..56c4d15a 100644 --- a/src/compiler/training/preparation/broker.ts +++ b/src/compiler/training/preparation/broker.ts @@ -14,8 +14,8 @@ export interface PreparedTrainingBroker { } /** - * Lowers `spawnfile.training-container.v3`'s broker block into the read-only - * `spawnfile.training-broker.v1` declaration the container's own root + * Lowers `spawnfile.training-container.v4`'s broker block into the read-only + * `spawnfile.training-broker.v2` declaration the container's own root * entrypoint reads, plus the launch bindings that carry the realm volume and * bootstrap leaf. * @@ -27,7 +27,8 @@ export const prepareTrainingBroker = async ( broker: NonNullable, root: string, staging: string, - write: boolean + write: boolean, + slots = 4 ): Promise => { const bootstrap = path.resolve(root, broker.bootstrap); assertNotDesktopGrokAuth(bootstrap); @@ -35,7 +36,9 @@ export const prepareTrainingBroker = async ( if (!info?.isFile() || info.isSymbolicLink()) throw Error("The training Grok bootstrap must be an existing regular credential leaf"); if ((info.mode & 0o077) !== 0) throw Error("The training Grok bootstrap must not be group- or world-accessible"); const declaration = parseTrainingBrokerDeclaration({ - version: "spawnfile.training-broker.v1", + version: "spawnfile.training-broker.v2", + slots, + maxRunRequests: broker.maxRunRequests, engine: broker.engine, agentId: broker.agentId, model: broker.model, diff --git a/src/compiler/training/preparation/contextModes.test.ts b/src/compiler/training/preparation/contextModes.test.ts index a18a8049..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 () => { diff --git a/src/compiler/training/preparation/contract.test.ts b/src/compiler/training/preparation/contract.test.ts index 01766915..515a3447 100644 --- a/src/compiler/training/preparation/contract.test.ts +++ b/src/compiler/training/preparation/contract.test.ts @@ -5,7 +5,7 @@ const config = () => ({ version: "spawnfile.training-container.v2", dockerContex integration: { settings: { input: "project", path: "." } }, inputs: [{ id: "project", source: "project", destination: "/run/training/inputs/project" }], output: { source: "out", destination: "/run/training/output" }, auth: [] }); it("keeps authoring strict and rejects ambiguous input and settings references", () => { - const base = config(); expect(trainingPreparationSchema.parse(base)).toEqual(base); + const base = config(); expect(trainingPreparationSchema.parse(base)).toEqual({ ...base, slots: 4 }); expect(() => trainingPreparationSchema.parse({ ...base, inputs: [...base.inputs, ...base.inputs] })).toThrow("unique"); expect(() => trainingPreparationSchema.parse({ ...base, auth: [{ source: "one", provider: "codex" }, { source: "two", provider: "codex" }] })).toThrow("unique"); expect(() => trainingPreparationSchema.parse({ ...base, integration: { settings: { input: "missing", path: "settings.json" } } })).toThrow("declared"); diff --git a/src/compiler/training/preparation/contract.ts b/src/compiler/training/preparation/contract.ts index c7874b97..09b4d479 100644 --- a/src/compiler/training/preparation/contract.ts +++ b/src/compiler/training/preparation/contract.ts @@ -32,6 +32,7 @@ const bounds = DAIMON_GROK_ENGINE_BROKER.turnLimits.bounds; * No Grok binary and no Grok auth staging enter the image. */ export const trainingBrokerSchema = z.object({ + maxRunRequests: z.number().int().positive().max(Number.MAX_SAFE_INTEGER).default(1000), engine: z.literal("grok"), agentId: z.string().min(1).max(128).regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/u), model: z.enum(DAIMON_GROK_BROKER_MODELS), @@ -50,7 +51,8 @@ export const trainingBrokerSchema = z.object({ }).strict(); export const trainingPreparationSchema = z.object({ - version: z.enum(["spawnfile.training-container.v2", "spawnfile.training-container.v3"]), + version: z.enum(["spawnfile.training-container.v2", "spawnfile.training-container.v3", "spawnfile.training-container.v4"]), + slots: z.number().int().min(1).max(8).default(4), dockerContext: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/u), image: z.union([z.object({ ref: trainingImageSchema }).strict(), z.object({ build: trainingBuildSchema }).strict()]), integration: z.object({ settings: reference }).strict(), @@ -65,8 +67,9 @@ export const trainingPreparationSchema = z.object({ auth: z.array(z.object({ source: local, provider: z.enum(["codex", "claude"]) }).strict()).max(2), broker: trainingBrokerSchema.optional() }).strict().superRefine((value, context) => { - if ((value.version === "spawnfile.training-container.v3") !== (value.broker !== undefined)) { - context.addIssue({ code: "custom", message: "Only spawnfile.training-container.v3 declares a broker slot, and it always does" }); + if (value.version === "spawnfile.training-container.v3") context.addIssue({ code: "custom", message: "Training-container v3 is unsupported; migrate to v4 with slots: 1" }); + if ((value.version === "spawnfile.training-container.v4") !== (value.broker !== undefined)) { + context.addIssue({ code: "custom", message: "Only spawnfile.training-container.v4 declares broker slots; migrate v3 by setting version to v4 and slots to 1" }); } if (value.broker && "build" in value.image && value.image.build.grok) { context.addIssue({ code: "custom", message: "A v3 training image never copies a Grok binary; judges use the native parent's pinned /usr/local/bin/grok" }); diff --git a/src/compiler/training/preparation/fixtures.test-helper.ts b/src/compiler/training/preparation/fixtures.test-helper.ts index 15ec9a5b..1380643b 100644 --- a/src/compiler/training/preparation/fixtures.test-helper.ts +++ b/src/compiler/training/preparation/fixtures.test-helper.ts @@ -23,7 +23,7 @@ export async function preparationFixture() { await put("paideia/dist/src/cli/main.js"); await put("bridge/pyproject.toml"); await put("bridge/requirements.lock"); await put("bridge/paideia_dspy/__init__.py"); await put("integration/entry.ts"); await put("bootstrap/start.ts"); await put("grok", "native-binary"); const config: TrainingPreparationConfig = { - version: "spawnfile.training-container.v2", dockerContext: "local", + version: "spawnfile.training-container.v2", slots: 4, dockerContext: "local", image: { build: { recipe: "daimon-dspy.v1", nativeImage: image, pythonImage: image, platform: "linux/arm64", paideia: "paideia", bridge: "bridge", claude: "claude", integration: { source: "integration", entry: "entry.ts" }, bootstrap: "bootstrap" } }, diff --git a/src/compiler/training/preparation/prepare.ts b/src/compiler/training/preparation/prepare.ts index 658b79bf..a3c92e69 100644 --- a/src/compiler/training/preparation/prepare.ts +++ b/src/compiler/training/preparation/prepare.ts @@ -34,8 +34,8 @@ export interface PreparedTraining { /** Reads only until the explicit dry-run boundary; preparation never executes project code. */ export async function prepareTraining(options: PrepareTrainingOptions): Promise { const config = trainingPreparationSchema.parse(await readBoundedJson(options.configPath)); - // A v2 declaration still lowers to the unchanged v1 launch config; only v3 carries a broker slot into Docker. - const launchVersion = config.version === "spawnfile.training-container.v3" ? config.version : "spawnfile.training-container.v1"; + // A v2 declaration still lowers to the unchanged v1 launch config; v4 carries the broker slots into Docker. + const launchVersion = config.version === "spawnfile.training-container.v4" ? config.version : "spawnfile.training-container.v1"; const root = path.dirname(path.resolve(options.configPath)); const auth = config.auth.map(entry => ({ ...entry, source: path.resolve(root, entry.source) })); const output = path.resolve(root, config.output.source), parent = path.dirname(output); @@ -65,7 +65,7 @@ export async function prepareTraining(options: PrepareTrainingOptions): Promise< if (repair && hashJson(config.integration) !== hashJson(repair.witness.manifest.config.integration)) throw Error("Repair integration settings binding changed"); const digest = hashJson({ config, sources: inputs.map(input => ({ id: input.id, digest: input.digest })), image: imagePlan?.digest ?? config.image, canonical: options.context.project.sourceDigest, ...(repair ? { repair: { witness: repair.witness.digest, parent: repair.manifestDigest } } : {}) }); - if (config.broker) await prepareTrainingBroker(config.broker, root, path.dirname(path.resolve(options.configPath)), false); + if (config.broker) await prepareTrainingBroker(config.broker, root, path.dirname(path.resolve(options.configPath)), false, config.slots); if (options.dryRun) return { digest, dryRun: true }; options.signal?.throwIfAborted(); for (const entry of auth) if (await exactPath(entry.source) !== entry.source || !(await lstat(entry.source)).isFile()) throw Error("Training auth must be a canonical regular leaf"); @@ -107,8 +107,8 @@ export async function prepareTraining(options: PrepareTrainingOptions): Promise< if (inspected.code !== 0) throw Error("Training image is unavailable"); image = inspected.stdout.trim(); } const mapped = mappedReceipt(digest, image, config); - const preparedBroker = config.broker ? await prepareTrainingBroker(config.broker, root, staging, true) : undefined; - const launch = trainingContainerConfigSchema.parse({ version: launchVersion, dockerContext: config.dockerContext, + const preparedBroker = config.broker ? await prepareTrainingBroker(config.broker, root, staging, true, config.slots) : undefined; + const launch = trainingContainerConfigSchema.parse({ version: launchVersion, slots: config.slots, dockerContext: config.dockerContext, inputs: inputs.map((input, index) => ({ source: staged[index], destination: input.destination })), output: { source: output, destination: "/run/training/output" }, auth, ...(preparedBroker ? { broker: preparedBroker.launch } : {}) }); await writeFile(configPath, JSON.stringify(launch), { flag: "wx", mode: 0o600 }); @@ -120,8 +120,8 @@ export async function prepareTraining(options: PrepareTrainingOptions): Promise< ...(repair ? { repair: { witness: repair.witness.digest, parent: repair.manifestDigest } } : {}) }); } catch (error) { if (owned) await rm(staging, { recursive: true, force: true }); throw error; } } - const expectedBroker = config.broker ? await prepareTrainingBroker(config.broker, root, staging, false) : undefined; - const expectedLaunch = trainingContainerConfigSchema.parse({ version: launchVersion, dockerContext: config.dockerContext, + const expectedBroker = config.broker ? await prepareTrainingBroker(config.broker, root, staging, false, config.slots) : undefined; + const expectedLaunch = trainingContainerConfigSchema.parse({ version: launchVersion, slots: config.slots, dockerContext: config.dockerContext, inputs: inputs.map((input, index) => ({ source: staged[index], destination: input.destination })), output: { source: output, destination: "/run/training/output" }, auth, ...(expectedBroker ? { broker: expectedBroker.launch } : {}) }); if (hashJson(await readBoundedJson(configPath)) !== hashJson(expectedLaunch) || diff --git a/src/compiler/training/rewind/memoryPackage.test.ts b/src/compiler/training/rewind/memoryPackage.test.ts index 6e142d72..3c0a0c35 100644 --- a/src/compiler/training/rewind/memoryPackage.test.ts +++ b/src/compiler/training/rewind/memoryPackage.test.ts @@ -60,9 +60,19 @@ it("materializes nonempty historical memory in an isolated production package wi await jsonl(history, "memory/agent%3Aa/self/events.jsonl", [event]); await writeFile(path.join(directory, "check.mjs"), ` import assert from "node:assert/strict"; +import { registerHooks } from "node:module"; import { readFile, readdir } from "node:fs/promises"; -import { materializeMemory } from "spawnfile/dist/compiler/training/rewind/memory.js"; -import { openHistory } from "spawnfile/dist/compiler/training/rewind/history.js"; +// A package below TMPDIR must not borrow dependencies from the enclosing checkout. +const packageRoot = new URL("./", import.meta.url).href; +registerHooks({ resolve(specifier, context, nextResolve) { + const result = nextResolve(specifier, context); + if (!result.url.startsWith("node:") && !result.url.startsWith(packageRoot)) { + throw Object.assign(new Error("Dependency escaped the production package: " + specifier), { code: "ERR_MODULE_NOT_FOUND" }); + } + return result; +} }); +const { materializeMemory } = await import("spawnfile/dist/compiler/training/rewind/memory.js"); +const { openHistory } = await import("spawnfile/dist/compiler/training/rewind/history.js"); const manifest = JSON.parse(await readFile("package.json", "utf8")); for (const name of Object.keys(manifest.devDependencies)) { assert.throws(() => import.meta.resolve(name), { code: "ERR_MODULE_NOT_FOUND" }); diff --git a/src/compiler/training/rewind/moltnetJson.test.ts b/src/compiler/training/rewind/moltnetJson.test.ts index dd2fe760..00b6e1c6 100644 --- a/src/compiler/training/rewind/moltnetJson.test.ts +++ b/src/compiler/training/rewind/moltnetJson.test.ts @@ -17,6 +17,33 @@ const temps: string[] = []; afterEach(async () => { await Promise.all(temps.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); }); describe("Moltnet JSON rewind", () => { + it("round-trips an empty actor type through the JSON cut unchanged and refuses a missing actor id", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "rewind-moltnet-actor-")); temps.push(root); + const bank = path.join(root, "moltnet/team%3Aorg/news"); await mkdir(bank, { recursive: true }); + const source = emptyMoltnetSnapshot(network, before); + const operator = { ...message("operator-message", before), from: { type: "", id: "operator" }, parts: [{ kind: "text" }] }; + const delivery = { id: "operator-event", type: "message.created", network_id: "news", created_at: before, message: operator }; + source.room_messages.assignment = [operator, message("future", future)]; + source.attachment_delivery_events = [delivery]; source.attachment_delivery_cursors = { writer: 1 }; + const file = path.join(bank, "state.json"), bytes = JSON.stringify(source); + await writeFile(file, bytes); + const context: InputContext = { at, record: null, history: await openHistory(root), destination: path.join(root, "out"), + input: { section: "networks", id: "news", declaredBy: "team:org", overridden: false, declarationBase: root, declaration: network } }; + expect(await materializeMoltnet(context)).toMatchObject({ fidelity: "approximate", basis: "created-at-cut" }); + const output = path.join(context.destination, "state.json"), cut = JSON.parse(await readFile(output, "utf8")); + expect(cut.room_messages.assignment).toEqual([operator]); + expect(cut.attachment_delivery_events).toEqual([delivery]); + expect(cut.attachment_delivery_cursors).toEqual({ writer: 1 }); + expect(await inspectMoltnetJson(output, at)).toMatchObject({ messages: 1, events: 1, findings: [] }); + expect(await readFile(file, "utf8")).toBe(bytes); + for (const from of [{ type: "" }, { type: "", id: "" }]) { + const malformed = JSON.parse(bytes); malformed.room_messages.assignment[0].from = from; + await writeFile(file, JSON.stringify(malformed)); + await expect(materializeMoltnet(context)).rejects.toThrow(/"id"/); + await expect(inspectMoltnetJson(file, at)).rejects.toThrow(/"id"/); + } + }); + it("cuts RFC3339Nano times chronologically, removes future topology, recomputes counters and remaps delivery cursors", () => { const source = emptyMoltnetSnapshot(network, before); source.room_messages.assignment = [message("old", before), message("boundary", at), message("future", future)]; diff --git a/src/compiler/training/rewind/moltnetSchemas.test.ts b/src/compiler/training/rewind/moltnetSchemas.test.ts new file mode 100644 index 00000000..db3cbc28 --- /dev/null +++ b/src/compiler/training/rewind/moltnetSchemas.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { moltnetEventSchema, moltnetMessageSchema, moltnetRoomSchema, + moltnetSnapshotSchema, moltnetThreadSchema } from "./moltnetSchemas.js"; + +const at = "2026-10-09T10:00:00Z"; +const message = { id: "message", network_id: "", target: { kind: "room", room_id: "room" }, + from: { type: "", id: "operator" }, parts: [{ kind: "text" }], created_at: at }; + +describe("Moltnet native schemas", () => { + it("preserves empty, unvalidated wire strings and omitted optional message fields", () => { + expect(moltnetMessageSchema.parse(message)).toEqual(message); + const populated = { ...message, origin: { network_id: "", message_id: "", received_via: "" }, + from: { ...message.from, name: "", network_id: "", fqid: "", credential_bound: false }, + parts: [{ kind: "file", text: "", media_type: "", url: "", filename: "", data: {} }], mentions: [] }; + expect(moltnetMessageSchema.parse(populated)).toEqual(populated); + }); + + it("accepts optional topology, event payload and delivery fields without inventing values", () => { + const room = { id: "room", name: "", created_at: at }; + const thread = { id: "thread", room_id: "room", message_count: 0 }; + const dm = { id: "dm", message_count: 0 }; + const event = { id: "event", type: "", network_id: "", created_at: at }; + expect(moltnetRoomSchema.parse(room)).toEqual(room); + expect(moltnetThreadSchema.parse(thread)).toEqual(thread); + expect(moltnetEventSchema.parse(event)).toEqual(event); + const embedded = { ...event, message, room, thread, dm, agent: { agent_id: "operator" }, replay_gap: {}, + pairing: { id: "pairing", remote_network_id: "", relay: { url: "" }, diagnostics: {} } }; + expect(moltnetEventSchema.parse(embedded)).toEqual(embedded); + const snapshot = moltnetSnapshotSchema.parse({ rooms: { room }, room_messages: { room: [message] }, + threads: {}, thread_messages: {}, direct_messages: {}, direct_members: {} }); + expect(snapshot.attachment_delivery_events).toEqual([]); + expect(snapshot.attachment_delivery_cursors).toEqual({}); + }); + + it.each([{ type: "" }, { type: "", id: "" }, { type: "", id: 1 }, { id: "operator" }, + { type: null, id: "operator" }, { type: "", id: "operator", unknown: true }])( + "refuses a malformed actor %j", (from) => { + expect(() => moltnetMessageSchema.parse({ ...message, from })).toThrow(); + expect(() => moltnetEventSchema.parse({ id: "event", type: "", network_id: "", created_at: at, + message: { ...message, from } })).toThrow(); + }); + + it("keeps required identities, part kinds, timestamps and delivery offsets strict", () => { + const valid = { ...message, from: { type: "agent", id: "operator" } }; + for (const patch of [{ id: "" }, { id: undefined }, { parts: [{ kind: "" }] }, + { created_at: "" }, { network_id: 1 }]) { + expect(() => moltnetMessageSchema.parse({ ...valid, ...patch })).toThrow(); + } + expect(() => moltnetRoomSchema.parse({ id: "", name: "", created_at: at })).toThrow(); + expect(() => moltnetThreadSchema.parse({ id: "thread", room_id: "", message_count: 0 })).toThrow(); + expect(() => moltnetEventSchema.parse({ id: "", type: "message.created", network_id: "", created_at: at })).toThrow(); + for (const cursor of [-1, 0.5, "0"]) expect(() => moltnetSnapshotSchema.parse({ rooms: {}, room_messages: {}, + threads: {}, thread_messages: {}, direct_messages: {}, direct_members: {}, + attachment_delivery_cursors: { operator: cursor } })).toThrow(); + }); +}); diff --git a/src/compiler/training/rewind/moltnetSchemas.ts b/src/compiler/training/rewind/moltnetSchemas.ts index c83b74df..2509e64d 100644 --- a/src/compiler/training/rewind/moltnetSchemas.ts +++ b/src/compiler/training/rewind/moltnetSchemas.ts @@ -8,7 +8,9 @@ export const moltnetInstantSchema = text.refine((value) => { }, "Expected an RFC3339 instant with at most nanosecond precision"); const instant = moltnetInstantSchema; const strings = z.array(text); -const actor = z.object({ type: id, id, name: text.optional(), network_id: text.optional(), +// Moltnet validates Actor.ID and Part.Kind, but Actor.Type is an unvalidated Go string. +// Accept its zero value as stored; never fill or normalize native payload fields. +const actor = z.object({ type: text, id, name: text.optional(), network_id: text.optional(), fqid: text.optional(), credential_bound: z.boolean().optional() }).strict(); const target = z.object({ kind: z.enum(["room", "thread", "dm"]), room_id: text.optional(), thread_id: text.optional(), parent_message_id: text.optional(), dm_id: text.optional(), @@ -47,7 +49,8 @@ const pairing = z.object({ id, remote_network_id: text, remote_network_name: tex http: strings.optional(), attach: strings.optional(), pair: strings.optional() }).strict().optional(), reason: text.optional(), message: text.optional() }).strict().optional() }).strict(); -export const moltnetEventSchema = z.object({ id, type: id, network_id: text, +// The delivery store persists Event.Type without validation as well. +export const moltnetEventSchema = z.object({ id, type: text, network_id: text, agent: agentEvent.optional(), message: moltnetMessageSchema.optional(), room: moltnetRoomSchema.optional(), thread: moltnetThreadSchema.optional(), dm: direct.optional(), pairing: pairing.optional(), replay_gap: z.object({ requested_event_id: text.optional(), oldest_event_id: text.optional(), diff --git a/src/compiler/training/rewind/moltnetSqlite.test.ts b/src/compiler/training/rewind/moltnetSqlite.test.ts index d322bd4a..13856dd4 100644 --- a/src/compiler/training/rewind/moltnetSqlite.test.ts +++ b/src/compiler/training/rewind/moltnetSqlite.test.ts @@ -60,6 +60,38 @@ const fixture = async () => { }; describe("Moltnet SQLite rewind", () => { + it("round-trips an empty actor type through the SQLite cut without rewriting stored JSON", async () => { + const { file, database, context } = await fixture(); + room(database, "assignment"); message(database, "operator-message", before); message(database, "future", future); + const from = '{ "type" : "", "id" : "operator" }', parts = '[ { "kind" : "text" } ]'; + database.prepare("UPDATE messages SET from_json = ?, parts_json = ? WHERE id = 'operator-message'").run(from, parts); + const delivery = `{ "id": "operator-event", "type": "message.created", "network_id": "news", "created_at": "${before}", + "message": { "id": "operator-message", "network_id": "news", "target": { "kind": "room", "room_id": "assignment" }, + "from": ${from}, "parts": ${parts}, "created_at": "${before}" } }`; + database.prepare("INSERT INTO attachment_delivery_events (event_id, event_json, created_at) VALUES (?, ?, ?)") + .run("operator-event", delivery, before); + database.prepare("INSERT INTO attachment_delivery_cursors VALUES ('writer', 1, ?)").run(before); + const stored = database.prepare("SELECT * FROM messages WHERE id = 'operator-message'").get(); + database.close(); const sourceBytes = await readFile(file); + expect(await materializeMoltnet(context)).toMatchObject({ fidelity: "approximate", basis: "created-at-cut" }); + const output = path.join(context.destination, "moltnet.sqlite"), result = new DatabaseSync(output, { readOnly: true }); + try { + expect(result.prepare("SELECT * FROM messages").all()).toEqual([stored]); + expect(result.prepare("SELECT event_json FROM attachment_delivery_events").all()).toEqual([{ event_json: delivery }]); + expect(result.prepare("SELECT event_seq FROM attachment_delivery_cursors").all()).toEqual([{ event_seq: 1 }]); + } finally { result.close(); } + expect(await inspectMoltnetSqlite(output, at)).toMatchObject({ messages: 1, events: 1, findings: [] }); + expect(await readFile(file)).toEqual(sourceBytes); + }); + + it.each([{ type: "" }, { type: "", id: "" }])("refuses a SQLite actor without a nonempty id: %j", async (from) => { + const { database, context, file } = await fixture(); + room(database, "assignment"); message(database, "operator-message", before); + database.prepare("UPDATE messages SET from_json = ?").run(JSON.stringify(from)); database.close(); + await expect(materializeMoltnet(context)).rejects.toThrow(/"id"/); + await expect(inspectMoltnetSqlite(file, at)).rejects.toThrow(/"id"/); + }); + it("cuts a copy, preserves nanos ordering, prunes topology/artifacts, restores rooms, and erases deleted payload bytes", async () => { const { file, database, context } = await fixture(); room(database, "assignment"); room(database, "future-room", future); room(database, "restored", before, future); room(database, "removed", before, before); diff --git a/src/compiler/upProject.recording.test.ts b/src/compiler/upProject.recording.test.ts index 34eb85ff..fd13c489 100644 --- a/src/compiler/upProject.recording.test.ts +++ b/src/compiler/upProject.recording.test.ts @@ -6,7 +6,7 @@ import path from "node:path"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { readDeployResources, readDeploymentRecord, deployResourceLogPath, deployResourcesAt, downDeployment } from "../deployment/index.js"; -import { capableDaimonManifest, useMatchingDaimonTestImage } from "../runtime/daimon/recording.test-helper.js"; +import { capableDaimonManifest } from "../runtime/daimon/recording.test-helper.js"; import { compileProject } from "./compileProject.js"; import { upProject } from "./upProject.js"; @@ -22,7 +22,6 @@ vi.mock("../deployment/index.js", async (original) => ({ let root: string; beforeEach(() => { root = mkdtempSync(path.join(os.tmpdir(), "spawnfile-up-recording-")); - useMatchingDaimonTestImage(root); vi.stubEnv("SPAWNFILE_HOME", path.join(root, "home")); vi.stubEnv("NOOPOLIS_RUN_ID", "recording-test"); }); diff --git a/src/compiler/workspaceBundleFakeDocker.test-helper.ts b/src/compiler/workspaceBundleFakeDocker.test-helper.ts index 56764930..d879b643 100644 --- a/src/compiler/workspaceBundleFakeDocker.test-helper.ts +++ b/src/compiler/workspaceBundleFakeDocker.test-helper.ts @@ -58,7 +58,7 @@ export interface FakeDocker { } export const createFakeDocker = async (directory: string): Promise => { - const command = path.join(directory, "fake-docker"), log = path.join(directory, "fake-docker.log"); + const command = path.join(directory, "fake-docker.cjs"), log = path.join(directory, "fake-docker.log"); await writeFile(command, SCRIPT); await chmod(command, 0o755); await writeFile(log, ""); diff --git a/src/compiler/workspaceBundleGenerated.test.ts b/src/compiler/workspaceBundleGenerated.test.ts index b3ee36a5..246866eb 100644 --- a/src/compiler/workspaceBundleGenerated.test.ts +++ b/src/compiler/workspaceBundleGenerated.test.ts @@ -29,7 +29,7 @@ const agentSpawnfile = (generated: string[]): string => [ ].join("\n"); const hostRecipe = [ - "command: [node, tools/bake.js, \"${output}\"]", "cwd: ../site", + "command: [node, tools/bake.cjs, \"${output}\"]", "cwd: ../site", "inputs:", " - root: ../site", " exclude: [runs.log]", "tools:", " - [cat, ../toolchain.txt]" ]; @@ -42,7 +42,7 @@ describe("generated bundles", () => { await mkdir(path.join(root, "site/tools"), { recursive: true }); await mkdir(path.join(root, "org"), { recursive: true }); await writeFile(path.join(root, ".gitignore"), "cache/\nout-*/\nsite/runs.log\n"); - await writeFile(path.join(root, "site/tools/bake.js"), GENERATOR); + await writeFile(path.join(root, "site/tools/bake.cjs"), GENERATOR); await writeFile(path.join(root, "site/source/a.txt"), "alpha"); await writeFile(path.join(root, "toolchain.txt"), "baker 1.0\n"); await writeFile(path.join(root, "org/Spawnfile"), agentSpawnfile(hostRecipe)); @@ -82,11 +82,11 @@ describe("generated bundles", () => { it("runs in a pinned image on the target platform with the output mounted", async () => { await writeFile(path.join(root, "org/Spawnfile"), agentSpawnfile([ - "command: [node, tools/bake.js, \"${output}\"]", "cwd: ../site", `image: "${IMAGE}"`, "inputs:", " - root: ../site", " exclude: [runs.log]", "tools:", " - [node, --version]" + "command: [node, tools/bake.cjs, \"${output}\"]", "cwd: ../site", `image: "${IMAGE}"`, "inputs:", " - root: ../site", " exclude: [runs.log]", "tools:", " - [node, --version]" ])); const result = await compile("out-image"); expect(await extract("out-image", result.report.workspace_bundles![0]!.sha256, "baked/a.txt.out")).toBe("ALPHA"); - const step = (await docker.invocations()).find((args) => args.includes("tools/bake.js"))!; + const step = (await docker.invocations()).find((args) => args.includes("tools/bake.cjs"))!; expect(step).toEqual(expect.arrayContaining(["--platform", "linux/amd64", "--workdir", "/spawnfile/work", IMAGE, "/spawnfile/output"])); expect(step.some((value) => value.endsWith(":/spawnfile/output"))).toBe(true); }, 60_000); @@ -107,7 +107,7 @@ describe("generated bundles", () => { it("pins a generated bundle by recipe without running its tools", async () => { const { pinWorkspaceBundle } = await import("./workspaceBundleResolve.js"); const resource = { - build: { generated: { command: ["node", "tools/bake.js", "${output}"], cwd: "../site", inputs: [{ root: "../site" }], tools: [["sh", "-c", "touch tool-ran"]] } }, + build: { generated: { command: ["node", "tools/bake.cjs", "${output}"], cwd: "../site", inputs: [{ root: "../site" }], tools: [["sh", "-c", "touch tool-ran"]] } }, id: "assets", kind: "bundle" as const, mode: "readonly" as const, mount: "./assets", sharing: "per_agent" as const, scope: { kind: "agent" as const, key: path.join(root, "org/Spawnfile"), name: "analyst" } }; @@ -132,7 +132,7 @@ describe("generated bundles", () => { expect(Date.now() - started).toBeLessThan(15_000); await write(["command: [sh, -c, \"rmdir $SPAWNFILE_BUNDLE_OUTPUT && ln -s /etc $SPAWNFILE_BUNDLE_OUTPUT\"]", "inputs:", " - root: ../site"]); await expect(compile("out-root-link")).rejects.toThrow(/replaced by something other than a directory/u); - await write(["command: [node, tools/bake.js, \"${output}\"]", "cwd: ../site", `image: "${IMAGE}"`, "inputs:", " - root: ../org"]); + await write(["command: [node, tools/bake.cjs, \"${output}\"]", "cwd: ../site", `image: "${IMAGE}"`, "inputs:", " - root: ../org"]); await expect(compile("out-outside")).rejects.toThrow(/must live inside cwd/u); await write(["command: [sh, -c, \"exit 5\"]", "cwd: ../site", `image: "${IMAGE}"`, "inputs:", " - root: ../site"]); await expect(compile("out-image-fail")).rejects.toThrow(/exited 5/u); diff --git a/src/compiler/workspaceBundleGit.test.ts b/src/compiler/workspaceBundleGit.test.ts index 8f207a13..78caccf4 100644 --- a/src/compiler/workspaceBundleGit.test.ts +++ b/src/compiler/workspaceBundleGit.test.ts @@ -1,9 +1,9 @@ import { execFile } from "node:child_process"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { listCommittedTree, listIndex, readStableSnapshot, readStatus, readStatusAgainstHead, resolveGitLocation, streamBlobs } from "./workspaceBundleGit.js"; @@ -23,7 +23,7 @@ describe("workspace bundle git queries", () => { await git(repo, "add", "."); await git(repo, "commit", "-qm", "init"); }); - afterEach(async () => { await rm(repo, { force: true, recursive: true }); }); + afterEach(async () => { vi.unstubAllEnvs(); await rm(repo, { force: true, recursive: true }); }); it("lists the committed tree, index, untracked files and status relative to a subdirectory", async () => { const tools = path.join(repo, "tools"); @@ -34,7 +34,11 @@ describe("workspace bundle git queries", () => { await writeFile(path.join(tools, "new.txt"), "new"); await writeFile(path.join(tools, "a.txt"), "changed"); expect(await readStatus(tools)).toEqual([{ index: " ", path: "tools/a.txt", worktree: "M" }, { index: "?", path: "tools/new.txt", worktree: "?" }]); - await expect(resolveGitLocation(os.tmpdir())).rejects.toThrow(/git query failed/u); + const nonRepository = path.join(repo, "non-repository"); + await mkdir(nonRepository); + // TMPDIR can be inside the checkout; stop Git before it discovers a parent repository. + vi.stubEnv("GIT_CEILING_DIRECTORIES", await realpath(repo)); + await expect(resolveGitLocation(nonRepository)).rejects.toThrow(/git query failed/u); }); it("streams blob contents in order, including empty and multi-chunk blobs", async () => { diff --git a/src/compiler/workspaceGitPins.test.ts b/src/compiler/workspaceGitPins.test.ts index 4a0e48c3..22686ecb 100644 --- a/src/compiler/workspaceGitPins.test.ts +++ b/src/compiler/workspaceGitPins.test.ts @@ -4,11 +4,11 @@ import { chmod, mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "n import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createDeployResources, deployResourcesAt } from "../deployment/index.js"; import { DISTRIBUTION_REPORT_OUTPUT_FILE, parseDistributionReport } from "../distribution/index.js"; -import { capableDaimonManifest, useMatchingDaimonTestImage } from "../runtime/daimon/recording.test-helper.js"; +import { capableDaimonManifest } from "../runtime/daimon/recording.test-helper.js"; import { compileProject } from "./compileProject.js"; import type { CompilePlan } from "./types.js"; import { resolveWorkspaceGitPins } from "./workspaceGitPins.js"; @@ -54,7 +54,7 @@ describe("fetch: build git resources", () => { await writeFile(path.join(source, "2026-10-08/stories/s1.md"), "# story one\n"); await writeFile(path.join(source, "README.md"), "private\n"); }); - afterEach(async () => { vi.unstubAllEnvs(); await rm(root, { force: true, recursive: true }); }); + afterEach(async () => { await rm(root, { force: true, recursive: true }); }); it("pins a moving branch to a commit at compile time, mounts it as a read-only bundle and reproduces it from the recorded commit", async () => { const first = await commitDay("s1 first\n"); @@ -200,7 +200,6 @@ describe("fetch: build git resources", () => { .replace("name: reporter", `name: ${agent}`).replace("runtime: openclaw", "runtime: daimon") .replace("provider: anthropic", "provider: openai").replace("method: claude-code", "method: codex")); } - useMatchingDaimonTestImage(root); const compiled = await compile("recording-out", { daimonContractManifest: capableDaimonManifest() }); const pins = compiled.report.workspace_git_resources!; expect(new Set(pins.map((pin) => pin.id)).size).toBe(1); diff --git a/src/compiler/workspaceRecordingOff.test.ts b/src/compiler/workspaceRecordingOff.test.ts index c808d728..3d3a647d 100644 --- a/src/compiler/workspaceRecordingOff.test.ts +++ b/src/compiler/workspaceRecordingOff.test.ts @@ -4,10 +4,10 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; -import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, expect, it } from "vitest"; import { createDistributionFingerprint, DISTRIBUTION_REPORT_OUTPUT_FILE, type DistributionReport } from "../distribution/index.js"; -import { capableDaimonManifest, useMatchingDaimonTestImage } from "../runtime/daimon/recording.test-helper.js"; +import { capableDaimonManifest } from "../runtime/daimon/recording.test-helper.js"; import { compileProject } from "./compileProject.js"; const run = promisify(execFile); @@ -39,10 +39,9 @@ beforeEach(async () => { })); } }); -afterEach(async () => { vi.unstubAllEnvs(); await rm(root, { recursive: true, force: true }); }); +afterEach(async () => { await rm(root, { recursive: true, force: true }); }); const compile = async (recording: boolean) => { - if (recording) useMatchingDaimonTestImage(root); if (recording) for (const name of ["a", "b"]) { const file = path.join(org, name, "Spawnfile"), manifest = JSON.parse(await readFile(file, "utf8")); await writeFile(file, json({ ...manifest, runtime: "daimon", execution: undefined })); diff --git a/src/dev/project.test.ts b/src/dev/project.test.ts index 6db4b56f..c932e957 100644 --- a/src/dev/project.test.ts +++ b/src/dev/project.test.ts @@ -39,7 +39,7 @@ const createTempDirectory = async (prefix: string): Promise => { const createFakeMoltnetCli = async (): Promise => { const directory = await createTempDirectory("spawnfile-dev-moltnet-"); - const cliPath = path.join(directory, "moltnet"); + const cliPath = path.join(directory, "moltnet.cjs"); await writeUtf8File( cliPath, [ diff --git a/src/runtime/container.test.ts b/src/runtime/container.test.ts index 19396d1c..4378e981 100644 --- a/src/runtime/container.test.ts +++ b/src/runtime/container.test.ts @@ -89,8 +89,12 @@ describe("runtime container install recipes", () => { ]); }); - it("requires a re-pin because the published Daimon image attests the old manifest", async () => { - await expect(createRuntimeInstallRecipe("daimon")).rejects.toThrow(/does not attest the compiler's exact contract manifest/u); + it("copies the CI-published Daimon index and checks the per-architecture receipt by default", async () => { + const recipe = await createRuntimeInstallRecipe("daimon"); + expect(recipe.copyCommands).toEqual([ + `COPY --from=noopolis/spawnfile-runtime-daimon@sha256:d4389f1cb99a8d2bf13134737ae058c0e48019d465bd35fbb250d7cde138b836 ${RUNTIME_INSTALL_ROOT}/daimon ${RUNTIME_INSTALL_ROOT}/daimon` + ]); + expect(recipe.commands[0]).toContain('case "$arch" in amd64) expected="sha256:9db4f4d5b62c505a445429da9ae8cf8bce21590bd1b67f449d17e427934a5421" ;; arm64) expected="sha256:8536fc5543378a8617a99bbdbddd0b6e909a6c0d4ed5f9bb5805d501a17ca90c" ;;'); }); it("installs overridden runtime packages from the vendored tarball path in the Pi recipe", async () => { @@ -205,7 +209,9 @@ describe("runtime container install recipes", () => { it("ignores the legacy Daimon base-image override without bypassing the manifest gate", async () => { process.env.SPAWNFILE_DAIMON_RUNTIME_BASE_IMAGE = "noopolis/spawnfile-runtime-daimon:legacy"; - await expect(createRuntimeInstallRecipe("daimon")).rejects.toThrow(/does not attest the compiler's exact contract manifest/u); + const recipe = await createRuntimeInstallRecipe("daimon"); + expect(recipe.baseImage).toBeUndefined(); + expect(recipe.copyCommands[0]).toContain("noopolis/spawnfile-runtime-daimon@sha256:d4389f1cb99a8d2bf13134737ae058c0e48019d465bd35fbb250d7cde138b836"); }); it("omits NOOPOLIS_RUN_ID from every recipe's env when unset", async () => { diff --git a/src/runtime/daimon/AGENTS.md b/src/runtime/daimon/AGENTS.md index 7a152f28..cfcfee78 100644 --- a/src/runtime/daimon/AGENTS.md +++ b/src/runtime/daimon/AGENTS.md @@ -70,8 +70,8 @@ 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 v4 adds a separate clocked config matrix, control protocol v3 and -projection v2. Training explicitly selects `clocked`; the default production +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 diff --git a/src/runtime/daimon/adapter.test.ts b/src/runtime/daimon/adapter.test.ts index e96bb804..4038f22d 100644 --- a/src/runtime/daimon/adapter.test.ts +++ b/src/runtime/daimon/adapter.test.ts @@ -382,23 +382,24 @@ describe("daimonAdapter", () => { ); }); - it("requires a re-pin for the new compiler contract", async () => { - await expect(createRuntimeInstallRecipe("daimon")).rejects.toThrow(/does not attest the compiler's exact contract manifest/u); + it("uses the pinned image because it attests the exact compiler contract", async () => { + const recipe = await createRuntimeInstallRecipe("daimon"); + expect(recipe.copyCommands[0]).toContain("noopolis/spawnfile-runtime-daimon@sha256:d4389f1cb99a8d2bf13134737ae058c0e48019d465bd35fbb250d7cde138b836"); }); - it("degrades schedules pending a re-pin and retains the MCP and non-Moltnet surface boundary", async () => { + it("lowers schedules while retaining the MCP and non-Moltnet surface boundary", async () => { await expect(daimonAdapter.compileAgent(createDaimonNode("schedule", "Schedule"))).resolves.toBeDefined(); await expect(daimonAdapter.compileAgent(createPiTestNode({ runtime: { name: "daimon", options: {} }, schedule: { every: "1m", kind: "every", prompt: "work" } }))).resolves.toMatchObject({ capabilities: expect.arrayContaining([ - expect.objectContaining({ key: "agent.schedule", outcome: "degraded" }) + expect.objectContaining({ key: "agent.schedule", outcome: "supported" }) ]) }); await expect(daimonAdapter.compileAgent(createPiTestNode({ runtime: { name: "daimon", options: {} }, schedule: { kind: "disabled" } }))).resolves.toMatchObject({ capabilities: expect.arrayContaining([ - expect.objectContaining({ key: "agent.schedule", outcome: "degraded" }) + expect.objectContaining({ key: "agent.schedule", outcome: "supported" }) ]) }); await expect(daimonAdapter.compileAgent(createPiTestNode({ runtime: { name: "daimon", options: { engine: "grok" } } diff --git a/src/runtime/daimon/attention.test.ts b/src/runtime/daimon/attention.test.ts index 469bfd6e..069cc5a8 100644 --- a/src/runtime/daimon/attention.test.ts +++ b/src/runtime/daimon/attention.test.ts @@ -50,8 +50,8 @@ describe("Daimon attention policy compilation", () => { await attest(); expect((await compile({})).agents[0].attention).toEqual({ maxBatchMessages: 8, maxBatchBytes: 12000 }); }); - it("requires a re-pin before the published image grants attention authority", async () => { - await expect(compile({ max_batch_messages: 2 })).rejects.toThrow(/attention is disabled.*does not attest/u); + it("accepts attention against the checked-in published pin without a local identity", async () => { + expect((await compile({ max_batch_messages: 2 })).agents[0].attention).toEqual({ maxBatchMessages: 2, maxBatchBytes: 12000 }); }); it.each([ null, [], "auto", { typo: 1 }, { max_batch_messages: 0 }, { max_batch_messages: 33 }, diff --git a/src/runtime/daimon/contract-manifest.json b/src/runtime/daimon/contract-manifest.json index 0488ef6e..136564fb 100644 --- a/src/runtime/daimon/contract-manifest.json +++ b/src/runtime/daimon/contract-manifest.json @@ -1 +1 @@ -{"activityResponseSchema":{"additionalProperties":false,"properties":{"items":{"items":{"additionalProperties":false,"properties":{"agentId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"id":{"format":"uuid","pattern":"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$","type":"string"},"kind":{"enum":["wake_started","wake_completed","wake_rejected","wake_aborted","agent_stopped"]},"occurredAt":{"format":"date-time","pattern":"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$","type":"string"},"wakeId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"}},"required":["id","agentId","kind","occurredAt"],"type":"object"},"maxItems":100,"type":"array"},"nextCursor":{"maxLength":16,"minLength":1,"pattern":"^(0|[1-9][0-9]{0,15})$","type":"string"},"version":{"const":"noopolis.daimon.organization-runtime-activity.v1"}},"required":["version","items"],"type":"object"},"activityV2ResponseSchema":{"additionalProperties":false,"properties":{"executions":{"items":{"additionalProperties":false,"properties":{"agent_id":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"delivery_ids":{"items":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"maxItems":32,"type":"array"},"execution_id":{"type":"string"},"state":{"const":"running"}},"required":["agent_id","execution_id","state","delivery_ids"],"type":"object"},"maxItems":32,"type":"array"},"items":{"items":{"additionalProperties":false,"properties":{"acceptance_id":{"type":"string"},"accepted_at":{"format":"date-time","pattern":"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$","type":"string"},"active":{"type":"boolean"},"agent_id":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"code":{"enum":["engine_failed","host_stopped","host_stopping","queued_wake_stopped","active_wake_aborted","queue_full","unknown_agent"]},"deferred":{"type":"boolean"},"delivery_id":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"execution_id":{"type":"string"},"queue_position":{"minimum":1,"type":"integer"},"request_digest":{"type":"string"},"state":{"enum":["accepted","running","completed","failed","stopped"]},"text":{"maxLength":16384,"type":"string"},"updated_at":{"format":"date-time","pattern":"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$","type":"string"},"version":{"const":"noopolis.daimon.wake-receipt-status.v2"}},"required":["version","acceptance_id","agent_id","delivery_id","request_digest","state","accepted_at","updated_at","active"],"type":"object"},"maxItems":2112,"type":"array"},"state":{"enum":["running","stopped"]},"version":{"const":"noopolis.daimon.organization-runtime-activity.v2"}},"required":["version","items"],"type":"object"},"agySubscriptionRealm":{"directoryMode":448,"durableMountPath":"/var/lib/spawnfile/daimon/agy-subscription-realm","fileMode":384,"maxUnlockBytes":4096,"unlockMountPath":"/var/lib/spawnfile/daimon/agy-unlock-secret","unlockSourceSlot":"agy-unlock-secret"},"attention":{"accounting":"execution-start-reservations","busyDispatch":"bounded-pending-message-batch","completion":"explicit-per-delivery","defaultMaxBatchBytes":12000,"defaultMaxBatchMessages":8,"enabledBy":"agents[].attention","idleDispatch":"immediate","unhandled":"deferred-until-new-input"},"consumedConfigFields":["version","host.bindHost","host.port","host.controlTokenEnv","agents[].id","agents[].name","agents[].instructions","agents[].workspacePath","agents[].runtimeHomePath","agents[].engine.kind","agents[].engine.model","agents[].engine.reasoningEffort","agents[].engine.codexSandbox","agents[].schedule.kind","agents[].schedule.interval_ms","agents[].schedule.cron","agents[].schedule.timezone","agents[].schedule.prompt","agents[].schedule.jitter_seconds","agents[].mcp","agents[].moltnet","agents[].memory","agents[].attention","agents[].recording"],"deliverySemantics":{"activeDeliveryIdempotency":"unbounded-until-terminal","concurrentSameAgentTurns":false,"externalEffectsExactlyOnce":false,"recovery":"at-least-once-with-stable-wake-id","terminalReceiptHorizon":2048},"engineCredentialMaterial":{"codex":{"destinationRelativePath":".codex/auth.json","directoryMode":448,"fileMode":384,"sourceRelativePath":".daimon-inbound/codex-auth","sourceSlot":"codex-auth"}},"grokEngineBroker":{"artifacts":{"arm64Sha256":"d0dc1c03197c3e90f09b66ad7c89b4f7b448d06dc84c03b247dce976d6018d6e","sourceSha256":"15836d7ad69f12ef581a6e5b8635c9db43b2177dac609160c0749d3d1fefbaa8","x64Sha256":"95aa9c272225e74ff9f45f54dde4bc30aee324e9ba24d8a876db9151c81b454d"},"backendSocketPath":"/run/daimon-engine-broker/backend.sock","bounds":{"capabilityBundleBytes":8196,"capabilityBytes":4096,"outputBytes":262144,"promptBytes":65536},"controlProtocolVersion":"noopolis.daimon.engine-broker.v3","controlSocketPath":"/run/daimon-engine-broker/control.sock","credentialHomePath":"/var/lib/spawnfile/daimon/grok-subscription-realm","grokCliArtifacts":{"arm64":{"bytes":136090504,"sha256":"39ab87666877d64ef3a40aa60fbe0c3b6a6acd7001b78fe60e2c76bb6cfc4a94","url":"https://storage.googleapis.com/grok-build-public-artifacts/cli/grok-1.0.34-linux-aarch64"},"x64":{"bytes":163035648,"sha256":"be5905e107d2b8b5f3c142d21ecfe4c8fd32a913d2fd551b788707930c4dc80d","url":"https://storage.googleapis.com/grok-build-public-artifacts/cli/grok-1.0.34-linux-x86_64"}},"grokCliBuild":"3736acbc8658","grokCliVersion":"1.0.34","grokExecutablePath":"/usr/local/bin/grok","identities":{"brokerUid":2100,"firstWorkerUid":2200,"organizationUid":2000},"inferenceGrants":{"bodyMembers":["messages","model","reasoning_effort","response_format","stream","stream_options"],"client":{"configSha256":{"grok-4.5":{"high":"ffbc33728b821e9854fbc7c93601e599225da421ecfd6ebf10d314afcc28d6f2","low":"a07f7436f1268bb399ec233c65d3b3d8fb99a11a1f175f8da1ca133c9367bc74","medium":"1f4c0d4dad1f3b09419b5739db6423a09e0049abc091594c64123a75dd53dfb9"},"grok-4.6":{"high":"5652656effa82f0c4f09cf8226b16e6140332a5a358b571194bb5563312367ac","low":"79314d039f787e4ebfec7dacf57adc969086b948f564dec008f0ed6367e6062f","medium":"6f538de0547c0c4e6a3f04ae08595ceadadabb06b75f6b6ee4c428744bb95cd8"},"grok-build":{"high":"98d16f2b7d12f4eb540d625c853e51d227933e204923e43e8b9b4176f10aca2c","low":"ca15c6a562a008227d39c51d3a3a83715663089b3784e8b46debb1fb67b3c4a1","medium":"01783fb6beadcf6f8486fff0836820ad43fab5662b812a907fe9cfdb83e9804d"}},"envKey":"DAIMON_INFERENCE_GRANT","modelId":"daimon-inference-grok"},"failureCodes":["auth_stale","grant_limit","invalid_request","unavailable"],"ledgerDedupeKey":["grant","request"],"ledgerVersion":"noopolis.daimon.inference-usage.v1","limits":{"maxRequests":64,"maxTokens":2000000},"maxInFlightRequestsPerGrant":1,"maxLiveGrants":8,"messageRoles":["system","user","assistant"],"purposes":["judge","optimizer"],"requestKinds":["request_inference_grant","release_inference_grant"],"tokenPrefix":"inference_","ttlMs":600000},"launcherSocketPath":"/run/daimon-engine-broker/launcher.sock","mcpFacade":{"host":"127.0.0.1","path":"/mcp","port":43124},"nativeAbiVersion":2,"nativeExecutablePath":"/opt/daimon/bin/daimon-engine-broker","projectionVersion":"noopolis.daimon.grok-broker-projection.v2","providerProxy":{"host":"127.0.0.1","port":43123},"registrationPath":"/etc/daimon-engine-broker/registrations.bin","serviceConfigPath":"/etc/daimon-engine-broker/service.json","serviceConfigVersions":["noopolis.daimon.engine-broker-service.v1","noopolis.daimon.engine-broker-service.v2"],"slotPreflightVersion":"noopolis.daimon.grok-slot-preflight.v2","turnLimits":{"bounds":{"maxRequests":[1,48],"maxTokens":[1,10000000],"timeoutMs":[1000,3600000]},"keys":["maxRequests","maxTokens","timeoutMs"],"limitReasons":["tokens","requests","timeout","none"],"maxInFlightRequests":1,"missingUsageEstimate":{"inputBytesPerToken":2,"outputTokens":4096},"requestUsageMaxTokens":500000,"tokenCeilingOvershoot":"at-most-one-request","v1Defaults":{"maxRequests":32,"maxTokens":300000,"timeoutMs":240000},"wakeMayOnlyLower":true},"turnRecordVersions":["noopolis.daimon.engine-broker-turn.v1","noopolis.daimon.engine-broker-turn.v2"],"turnStorePath":"/var/lib/spawnfile/daimon/grok-subscription-realm/turns","wakeLimitEnvironment":{"maxTokens":"DAIMON_ENGINE_WAKE_TOKEN_CEILING","timeoutMs":"DAIMON_ENGINE_WAKE_TIMEOUT_MS"},"worker":{"clockedConfigSha256":{"grok-4.5":{"high":"bf9d36286ec81fd5d37a726c5835d11349e8cc91bafe1428fef7171a3c509091","low":"831a68af36566ae24c1a8301b3fa866167fd173bf22334c2caf897c6d41c8a15","medium":"f517dfefb0b6ee6252d20bd81c057afca6d6f382c92c686d0b83e91a4a77ac07"},"grok-4.6":{"high":"7aac64db19a2f59967bbbe1ed7a8b748985927f0f15cad847eb8cd20535eb70b","low":"634ec9ce49c9e8d508b574e7b183678330359cfed63738df209a38963c730dab","medium":"dacd7e1c2de68d9edd2250b436d76e67046d071f163d56b7c291a315f2342e8c"},"grok-build":{"high":"127e592c0df2c43de51559ae7c90e607f59e2b1ccf5b8c0a082cd64e46ab7823","low":"22adfcb78a281e768f878996de8665e56ff698bf9c46bf06ce894ef9c21c2512","medium":"74c5362427c371e06bbf4ea5f3e10024fdb8ea468103b2206f7f4e873572932a"}},"configSha256":{"grok-4.5":{"high":"c63c3387ce92d94ec3f690abfe98942afcd7c9e17ff84816bbe751f340ab251f","low":"8247127c3625ff7c5d8d527a53596b89ec6557a821ac46cfd00bd122b90daff6","medium":"59288cee61297bb8c002097061a48f77b09d310754187a253ee089f7172a9155"},"grok-4.6":{"high":"65b0212564fb74042b1503d293fb8d3620276033264c0efade2a539ca09218e3","low":"ab58499ac32678097c146479896f2b8a8e2b0e39aea22dc0a60b6227e370538e","medium":"df1a5cc84346e7f6bf6090492fbd19faaefb42953e3bb2e8c6cbc0572242403f"},"grok-build":{"high":"a23724e00d670caee185ba7690d2daa868173e53905cf446f5329666f01ab4e3","low":"fb343f2809903f26d21681470943235031f946e99085542fd89555eb7782cbb5","medium":"8a587ef75c90eab70d19b24583e60051d6fba9d90c558839fdbb15588b4cc656"}},"defaultModel":"grok-4.6","defaultReasoningEffort":"low","home":{"directory":{"group":"worker","mode":1017,"uid":0},"organizationRuntimeHome":{"group":"worker","mode":456,"owner":"organization"},"privateTmp":{"mode":448,"owner":"worker","relativeToWorkerHome":"tmp"},"readOnlyFiles":{"gid":0,"mode":292,"names":["config.toml","managed_config.toml","requirements.toml","sandbox.toml","trusted_folders.toml"],"uid":0},"sandboxEvents":{"group":"broker","mode":416,"owner":"worker","relativePath":"sessions/sandbox-events.jsonl"},"sessionsDirectory":{"group":"worker","mode":1017,"relativePath":"sessions","uid":0},"sharedTmp":{"maxGroupExclusive":2200,"mode":1020,"otherMode":4,"paths":["/tmp","/var/tmp"],"uid":0},"spillDirectory":{"fileMode":416,"group":"worker","mode":1512,"owner":"organization","relativeToRuntimeHome":"tool-output"}},"maxTurns":48,"modelId":"daimon-broker-grok","models":["grok-4.6","grok-4.5","grok-build"],"reasoningEfforts":["low","medium","high"],"systemPromptSha256":"2c31c0085a54a4efbf9c0cf0b8124c56e47f38691b7f0c7fa233a74abaa8ddf8","taskClock":{"directoryMode":493,"directoryPath":"/run/daimon-engine-broker/task-clock","fileMode":420,"maxRegistrations":1,"ownerUid":2100,"preloadPath":"/usr/local/lib/daimon/libfaketime.so.1","timestampFilePath":"/run/daimon-engine-broker/task-clock/faketime"},"toolIds":["run_terminal_cmd","read_file","grep","list_dir","search_tool","use_tool"],"visibleTools":["grep","list_dir","read_file","run_terminal_command","search_tool","use_tool"]}},"grokSubscriptionRealm":{"agentCredentialRelativePath":".grok/auth.json","bootstrapMountPath":"/var/lib/spawnfile/daimon/grok-bootstrap-auth","bootstrapSourceSlot":"grok-auth","directoryMode":448,"durableMountPath":"/var/lib/spawnfile/daimon/grok-subscription-realm","fileMode":384,"maxCredentialBytes":65536},"healthResponseSchema":{"additionalProperties":false,"properties":{"agents":{"items":{"additionalProperties":false,"properties":{"agentId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"state":{"enum":["starting","running","stopping","stopped","idle","failed"]}},"required":["agentId","state"],"type":"object"},"maxItems":32,"type":"array"},"state":{"enum":["starting","running","stopping","stopped"]},"version":{"const":"noopolis.daimon.organization-runtime-health.v1"}},"required":["version","state","agents"],"type":"object"},"operatorDrain":{"authorization":"control-bearer","blockedReason":"operator_stop","drainRoute":"POST /v2/drain","queuedWakes":"retained","response":"workAvailabilityResponseSchema","resumeClearsOperatorStop":false,"resumeRoute":"POST /v2/resume","runningTurns":"finish","scope":"process"},"organizationRuntimeConfigSchema":{"$id":"noopolis.daimon.organization-runtime.v1","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"agents":{"items":{"additionalProperties":false,"properties":{"attention":{"additionalProperties":false,"properties":{"maxBatchBytes":{"maximum":12000,"minimum":1024,"type":"integer"},"maxBatchMessages":{"maximum":32,"minimum":1,"type":"integer"},"maxExecutions":{"maximum":9007199254740991,"minimum":1,"type":"integer"},"maxTokens":{"maximum":9007199254740991,"minimum":1,"type":"integer"}},"type":"object"},"engine":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"grok"}}},"then":{"dependentRequired":{"model":["reasoningEffort"],"reasoningEffort":["model"]},"properties":{"codexSandbox":false,"model":{"enum":["grok-4.6","grok-4.5","grok-build"]},"reasoningEffort":{"enum":["low","medium","high"]}}}},{"if":{"properties":{"kind":{"const":"agy"}}},"then":{"properties":{"codexSandbox":false,"model":false,"reasoningEffort":false}}}],"properties":{"codexSandbox":{"additionalProperties":false,"properties":{"mode":{"const":"workspace-write"},"networkAccess":{"const":false},"webSearch":{"const":"disabled"}},"required":["mode","networkAccess","webSearch"],"type":"object"},"kind":{"enum":["codex","grok","agy"]},"model":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"reasoningEffort":{"enum":["none","minimal","low","medium","high","xhigh","max","ultra","persistent"]}},"required":["kind"],"type":"object"},"id":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"instructions":{"maxLength":16384,"minLength":1,"pattern":"\\S","type":"string"},"mcp":{"items":{"additionalProperties":false,"properties":{"args":{"items":{"maxLength":4096,"type":"string"},"maxItems":32,"type":"array"},"authSecretEnv":{"pattern":"^[A-Za-z_][A-Za-z0-9_]*$","type":"string"},"command":{"pattern":"^/","type":"string"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"name":{"maxLength":4096,"minLength":1,"type":"string"},"tools":{"items":{"maxLength":4096,"minLength":1,"type":"string"},"maxItems":32,"minItems":1,"type":"array","uniqueItems":true},"transport":{"enum":["stdio","sse","streamable_http"]},"url":{"type":"string"}},"required":["name","transport","args","env","tools"],"type":"object"},"maxItems":8,"type":"array"},"memory":{"additionalProperties":false,"properties":{"runtimeHomePath":{"maxLength":4096,"pattern":"^/","type":"string"},"source":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"tokenBudget":{"maximum":1000000,"minimum":1,"type":"integer"}},"required":["runtimeHomePath"],"type":"object"},"moltnet":{"additionalProperties":false,"properties":{"cliPath":{"pattern":"^/","type":"string"},"configPath":{"pattern":"^/","type":"string"},"networks":{"items":{"additionalProperties":false,"properties":{"dms":{"type":"boolean"},"id":{"minLength":1,"type":"string"},"rooms":{"items":{"minLength":1,"type":"string"},"type":"array","uniqueItems":true}},"required":["id","rooms","dms"],"type":"object"},"maxItems":16,"type":"array"}},"required":["cliPath","configPath","networks"],"type":"object"},"name":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"recording":{"additionalProperties":false,"properties":{"directory":{"maxLength":4096,"pattern":"^/[^\\u0000]*$","type":"string"},"keepMs":{"maximum":9007199254740991,"minimum":1,"type":"integer"},"snapshots":{"items":{"additionalProperties":false,"properties":{"id":{"pattern":"^[A-Za-z0-9._-]{1,128}$","type":"string"},"path":{"maxLength":4096,"pattern":"^/[^\\u0000]*$","type":"string"}},"required":["id","path"],"type":"object"},"maxItems":16,"type":"array","uniqueItems":true}},"required":["directory","keepMs","snapshots"],"type":"object"},"runtimeHomePath":{"maxLength":4096,"pattern":"^/","type":"string"},"workspacePath":{"maxLength":4096,"pattern":"^/","type":"string"}},"required":["id","name","instructions","workspacePath","runtimeHomePath","engine"],"type":"object"},"maxItems":32,"minItems":1,"type":"array"},"host":{"additionalProperties":false,"properties":{"bindHost":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"controlTokenEnv":{"maxLength":4096,"pattern":"^[A-Za-z_][A-Za-z0-9_]*$","type":"string"},"port":{"maximum":65535,"minimum":1,"type":"integer"}},"required":["bindHost","port","controlTokenEnv"],"type":"object"},"version":{"const":"noopolis.daimon.organization-runtime.v1"}},"required":["version","host","agents"],"type":"object"},"organizationRuntimeConfigV2Schema":{"$id":"noopolis.daimon.organization-runtime.v2","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"agents":{"items":{"additionalProperties":false,"properties":{"attention":{"additionalProperties":false,"properties":{"maxBatchBytes":{"maximum":12000,"minimum":1024,"type":"integer"},"maxBatchMessages":{"maximum":32,"minimum":1,"type":"integer"},"maxExecutions":{"maximum":9007199254740991,"minimum":1,"type":"integer"},"maxTokens":{"maximum":9007199254740991,"minimum":1,"type":"integer"}},"type":"object"},"engine":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"grok"}}},"then":{"dependentRequired":{"model":["reasoningEffort"],"reasoningEffort":["model"]},"properties":{"codexSandbox":false,"model":{"enum":["grok-4.6","grok-4.5","grok-build"]},"reasoningEffort":{"enum":["low","medium","high"]}}}},{"if":{"properties":{"kind":{"const":"agy"}}},"then":{"properties":{"codexSandbox":false,"model":false,"reasoningEffort":false}}}],"properties":{"codexSandbox":{"additionalProperties":false,"properties":{"mode":{"const":"workspace-write"},"networkAccess":{"const":false},"webSearch":{"const":"disabled"}},"required":["mode","networkAccess","webSearch"],"type":"object"},"kind":{"enum":["codex","grok","agy"]},"model":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"reasoningEffort":{"enum":["none","minimal","low","medium","high","xhigh","max","ultra","persistent"]}},"required":["kind"],"type":"object"},"id":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"instructions":{"maxLength":16384,"minLength":1,"pattern":"\\S","type":"string"},"mcp":{"items":{"additionalProperties":false,"properties":{"args":{"items":{"maxLength":4096,"type":"string"},"maxItems":32,"type":"array"},"authSecretEnv":{"pattern":"^[A-Za-z_][A-Za-z0-9_]*$","type":"string"},"command":{"pattern":"^/","type":"string"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"name":{"maxLength":4096,"minLength":1,"type":"string"},"tools":{"items":{"maxLength":4096,"minLength":1,"type":"string"},"maxItems":32,"minItems":1,"type":"array","uniqueItems":true},"transport":{"enum":["stdio","sse","streamable_http"]},"url":{"type":"string"}},"required":["name","transport","args","env","tools"],"type":"object"},"maxItems":8,"type":"array"},"memory":{"additionalProperties":false,"properties":{"runtimeHomePath":{"maxLength":4096,"pattern":"^/","type":"string"},"source":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"tokenBudget":{"maximum":1000000,"minimum":1,"type":"integer"}},"required":["runtimeHomePath"],"type":"object"},"moltnet":{"additionalProperties":false,"properties":{"cliPath":{"pattern":"^/","type":"string"},"configPath":{"pattern":"^/","type":"string"},"networks":{"items":{"additionalProperties":false,"properties":{"dms":{"type":"boolean"},"id":{"minLength":1,"type":"string"},"rooms":{"items":{"minLength":1,"type":"string"},"type":"array","uniqueItems":true}},"required":["id","rooms","dms"],"type":"object"},"maxItems":16,"type":"array"}},"required":["cliPath","configPath","networks"],"type":"object"},"name":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"recording":{"additionalProperties":false,"properties":{"directory":{"maxLength":4096,"pattern":"^/[^\\u0000]*$","type":"string"},"keepMs":{"maximum":9007199254740991,"minimum":1,"type":"integer"},"snapshots":{"items":{"additionalProperties":false,"properties":{"id":{"pattern":"^[A-Za-z0-9._-]{1,128}$","type":"string"},"path":{"maxLength":4096,"pattern":"^/[^\\u0000]*$","type":"string"}},"required":["id","path"],"type":"object"},"maxItems":16,"type":"array","uniqueItems":true}},"required":["directory","keepMs","snapshots"],"type":"object"},"runtimeHomePath":{"maxLength":4096,"pattern":"^/","type":"string"},"schedule":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"disabled"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"interval_ms":{"maximum":31536000000,"minimum":1,"type":"integer"},"jitter_seconds":{"maximum":3600,"minimum":0,"type":"integer"},"kind":{"const":"every"},"prompt":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"}},"required":["kind","interval_ms","prompt"],"type":"object"},{"additionalProperties":false,"properties":{"cron":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"jitter_seconds":{"maximum":3600,"minimum":0,"type":"integer"},"kind":{"const":"cron"},"prompt":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"timezone":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"}},"required":["kind","cron","timezone","prompt"],"type":"object"}]},"workspacePath":{"maxLength":4096,"pattern":"^/","type":"string"}},"required":["id","name","instructions","workspacePath","runtimeHomePath","engine","schedule"],"type":"object"},"maxItems":32,"minItems":1,"type":"array"},"host":{"additionalProperties":false,"properties":{"bindHost":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"controlTokenEnv":{"maxLength":4096,"pattern":"^[A-Za-z_][A-Za-z0-9_]*$","type":"string"},"port":{"maximum":65535,"minimum":1,"type":"integer"}},"required":["bindHost","port","controlTokenEnv"],"type":"object"},"version":{"const":"noopolis.daimon.organization-runtime.v2"}},"required":["version","host","agents"],"type":"object"},"supportedEngineKinds":["agy","codex","grok"],"version":"noopolis.daimon.runtime-contract-manifest.v4","wakeAcceptanceTypes":["manual","message","schedule","external"],"wakeMoments":{"captureDeadlineMs":60000,"completionSuffix":".complete","configField":"agents[].recording","finalizationDeadlineMs":1000,"maxManifestBytes":134217728,"maxRowBytes":4194304,"maxSnapshotEntries":200000,"maxSnapshotRoots":16,"rowsFile":"wake-moments.jsonl","snapshotsDirectory":"snapshots","version":"noopolis.daimon.wake-moment.v1"},"wakeRequestSchema":{"additionalProperties":false,"properties":{"agentId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"event":{"additionalProperties":false,"properties":{"id":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"kind":{"enum":["manual","message","schedule","external"]},"occurredAt":{"format":"date-time","pattern":"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$","type":"string"},"text":{"maxLength":4096,"type":"string"},"version":{"const":"noopolis.daimon.wake.v1"}},"required":["version","id","kind","text","occurredAt"],"type":"object"}},"required":["agentId","event"],"type":"object"},"wakeResultSchema":{"oneOf":[{"additionalProperties":false,"properties":{"agentId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"durationMs":{"minimum":0,"type":"integer"},"status":{"const":"completed"},"text":{"maxLength":4096,"type":"string"},"version":{"const":"noopolis.daimon.wake-result.v1"},"wakeId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"}},"required":["version","status","agentId","wakeId","text","durationMs"],"type":"object"},{"additionalProperties":false,"properties":{"agentId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"code":{"enum":["unauthorized","unknown_agent","queue_full","durable_inbox_required"]},"status":{"const":"rejected"},"version":{"const":"noopolis.daimon.wake-result.v1"},"wakeId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"}},"required":["version","status","agentId","wakeId","code"],"type":"object"},{"additionalProperties":false,"properties":{"agentId":{"maxLength":4096,"type":"string"},"code":{"const":"invalid_request"},"status":{"const":"rejected"},"version":{"const":"noopolis.daimon.wake-result.v1"},"wakeId":{"maxLength":4096,"type":"string"}},"required":["version","status","agentId","wakeId","code"],"type":"object"},{"additionalProperties":false,"properties":{"agentId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"code":{"enum":["host_stopping","host_stopped","queued_wake_stopped","active_wake_aborted"]},"status":{"const":"stopped"},"version":{"const":"noopolis.daimon.wake-result.v1"},"wakeId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"}},"required":["version","status","agentId","wakeId","code"],"type":"object"},{"additionalProperties":false,"properties":{"agentId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"code":{"const":"engine_failed"},"status":{"const":"failed"},"version":{"const":"noopolis.daimon.wake-result.v1"},"wakeId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"}},"required":["version","status","agentId","wakeId","code"],"type":"object"}]},"workAvailabilityResponseSchema":{"additionalProperties":false,"properties":{"agents":{"items":{"additionalProperties":false,"properties":{"agent_id":{"type":"string"},"budget":{"additionalProperties":false,"properties":{"agent_executions_remaining":{"minimum":0,"type":"integer"},"agent_executions_used":{"minimum":0,"type":"integer"},"agent_tokens_remaining":{"minimum":0,"type":"integer"},"agent_tokens_used":{"minimum":0,"type":"integer"},"armed":{"type":"boolean"},"epoch":{"type":"string"},"executions_remaining":{"minimum":0,"type":"integer"},"executions_used":{"minimum":0,"type":"integer"},"reason":{"type":"string"},"state":{"enum":["available","paused","stopped"]},"tokens_remaining":{"minimum":0,"type":"integer"},"tokens_used":{"minimum":0,"type":"integer"}},"required":["armed","epoch","state","executions_used","executions_remaining","tokens_used","tokens_remaining","agent_executions_used","agent_tokens_used"],"type":"object"},"deferred":{"minimum":0,"type":"integer"},"error":{"type":"string"},"pending":{"minimum":0,"type":"integer"},"running":{"type":"boolean"}},"required":["agent_id","pending","running","deferred","budget"],"type":"object"},"maxItems":32,"type":"array"},"drain":{"additionalProperties":false,"properties":{"since":{"type":"string"},"state":{"enum":["draining","drained"]}},"required":["state","since"],"type":"object"},"state":{"enum":["running","paused","stopped"]},"version":{"const":"noopolis.daimon.work-availability.v1"}},"required":["version","state","agents"],"type":"object"},"workBlockedSchema":{"additionalProperties":false,"properties":{"reason":{"enum":["operator_stop","ledger_unavailable","host_stopping","host_stopped","queue_full"]},"retry_after_ms":{"maximum":300000,"minimum":1000,"type":"integer"},"version":{"const":"noopolis.daimon.work-blocked.v1"}},"required":["version","reason","retry_after_ms"],"type":"object"}} +{"activityResponseSchema":{"additionalProperties":false,"properties":{"items":{"items":{"additionalProperties":false,"properties":{"agentId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"id":{"format":"uuid","pattern":"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$","type":"string"},"kind":{"enum":["wake_started","wake_completed","wake_rejected","wake_aborted","agent_stopped"]},"occurredAt":{"format":"date-time","pattern":"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$","type":"string"},"wakeId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"}},"required":["id","agentId","kind","occurredAt"],"type":"object"},"maxItems":100,"type":"array"},"nextCursor":{"maxLength":16,"minLength":1,"pattern":"^(0|[1-9][0-9]{0,15})$","type":"string"},"version":{"const":"noopolis.daimon.organization-runtime-activity.v1"}},"required":["version","items"],"type":"object"},"activityV2ResponseSchema":{"additionalProperties":false,"properties":{"executions":{"items":{"additionalProperties":false,"properties":{"agent_id":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"delivery_ids":{"items":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"maxItems":32,"type":"array"},"execution_id":{"type":"string"},"state":{"const":"running"}},"required":["agent_id","execution_id","state","delivery_ids"],"type":"object"},"maxItems":32,"type":"array"},"items":{"items":{"additionalProperties":false,"properties":{"acceptance_id":{"type":"string"},"accepted_at":{"format":"date-time","pattern":"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$","type":"string"},"active":{"type":"boolean"},"agent_id":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"code":{"enum":["engine_failed","host_stopped","host_stopping","queued_wake_stopped","active_wake_aborted","queue_full","unknown_agent"]},"deferred":{"type":"boolean"},"delivery_id":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"execution_id":{"type":"string"},"queue_position":{"minimum":1,"type":"integer"},"request_digest":{"type":"string"},"state":{"enum":["accepted","running","completed","failed","stopped"]},"text":{"maxLength":16384,"type":"string"},"updated_at":{"format":"date-time","pattern":"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$","type":"string"},"version":{"const":"noopolis.daimon.wake-receipt-status.v2"}},"required":["version","acceptance_id","agent_id","delivery_id","request_digest","state","accepted_at","updated_at","active"],"type":"object"},"maxItems":2112,"type":"array"},"state":{"enum":["running","stopped"]},"version":{"const":"noopolis.daimon.organization-runtime-activity.v2"}},"required":["version","items"],"type":"object"},"agySubscriptionRealm":{"directoryMode":448,"durableMountPath":"/var/lib/spawnfile/daimon/agy-subscription-realm","fileMode":384,"maxUnlockBytes":4096,"unlockMountPath":"/var/lib/spawnfile/daimon/agy-unlock-secret","unlockSourceSlot":"agy-unlock-secret"},"attention":{"accounting":"execution-start-reservations","busyDispatch":"bounded-pending-message-batch","completion":"explicit-per-delivery","defaultMaxBatchBytes":12000,"defaultMaxBatchMessages":8,"enabledBy":"agents[].attention","idleDispatch":"immediate","unhandled":"deferred-until-new-input"},"consumedConfigFields":["version","host.bindHost","host.port","host.controlTokenEnv","agents[].id","agents[].name","agents[].instructions","agents[].workspacePath","agents[].runtimeHomePath","agents[].engine.kind","agents[].engine.model","agents[].engine.reasoningEffort","agents[].engine.codexSandbox","agents[].schedule.kind","agents[].schedule.interval_ms","agents[].schedule.cron","agents[].schedule.timezone","agents[].schedule.prompt","agents[].schedule.jitter_seconds","agents[].mcp","agents[].moltnet","agents[].memory","agents[].attention","agents[].recording"],"deliverySemantics":{"activeDeliveryIdempotency":"unbounded-until-terminal","concurrentSameAgentTurns":false,"externalEffectsExactlyOnce":false,"recovery":"at-least-once-with-stable-wake-id","terminalReceiptHorizon":2048},"engineCredentialMaterial":{"codex":{"destinationRelativePath":".codex/auth.json","directoryMode":448,"fileMode":384,"sourceRelativePath":".daimon-inbound/codex-auth","sourceSlot":"codex-auth"}},"grokEngineBroker":{"artifacts":{"arm64Sha256":"d0dc1c03197c3e90f09b66ad7c89b4f7b448d06dc84c03b247dce976d6018d6e","sourceSha256":"15836d7ad69f12ef581a6e5b8635c9db43b2177dac609160c0749d3d1fefbaa8","x64Sha256":"95aa9c272225e74ff9f45f54dde4bc30aee324e9ba24d8a876db9151c81b454d"},"backendSocketPath":"/run/daimon-engine-broker/backend.sock","bounds":{"capabilityBundleBytes":8196,"capabilityBytes":4096,"outputBytes":262144,"promptBytes":65536},"controlProtocolVersion":"noopolis.daimon.engine-broker.v4","controlSocketPath":"/run/daimon-engine-broker/control.sock","credentialHomePath":"/var/lib/spawnfile/daimon/grok-subscription-realm","grokCliArtifacts":{"arm64":{"bytes":136090504,"sha256":"39ab87666877d64ef3a40aa60fbe0c3b6a6acd7001b78fe60e2c76bb6cfc4a94","url":"https://storage.googleapis.com/grok-build-public-artifacts/cli/grok-1.0.34-linux-aarch64"},"x64":{"bytes":163035648,"sha256":"be5905e107d2b8b5f3c142d21ecfe4c8fd32a913d2fd551b788707930c4dc80d","url":"https://storage.googleapis.com/grok-build-public-artifacts/cli/grok-1.0.34-linux-x86_64"}},"grokCliBuild":"3736acbc8658","grokCliVersion":"1.0.34","grokExecutablePath":"/usr/local/bin/grok","identities":{"brokerUid":2100,"firstWorkerUid":2200,"organizationUid":2000},"inferenceGrants":{"bodyMembers":["messages","model","reasoning_effort","response_format","stream","stream_options"],"client":{"configSha256":{"grok-4.5":{"high":"ffbc33728b821e9854fbc7c93601e599225da421ecfd6ebf10d314afcc28d6f2","low":"a07f7436f1268bb399ec233c65d3b3d8fb99a11a1f175f8da1ca133c9367bc74","medium":"1f4c0d4dad1f3b09419b5739db6423a09e0049abc091594c64123a75dd53dfb9"},"grok-4.6":{"high":"5652656effa82f0c4f09cf8226b16e6140332a5a358b571194bb5563312367ac","low":"79314d039f787e4ebfec7dacf57adc969086b948f564dec008f0ed6367e6062f","medium":"6f538de0547c0c4e6a3f04ae08595ceadadabb06b75f6b6ee4c428744bb95cd8"},"grok-build":{"high":"98d16f2b7d12f4eb540d625c853e51d227933e204923e43e8b9b4176f10aca2c","low":"ca15c6a562a008227d39c51d3a3a83715663089b3784e8b46debb1fb67b3c4a1","medium":"01783fb6beadcf6f8486fff0836820ad43fab5662b812a907fe9cfdb83e9804d"}},"envKey":"DAIMON_INFERENCE_GRANT","modelId":"daimon-inference-grok"},"failureCodes":["auth_stale","grant_limit","invalid_request","unavailable"],"ledgerDedupeKey":["grant","request"],"ledgerVersion":"noopolis.daimon.inference-usage.v1","limits":{"maxRequests":64,"maxTokens":2000000},"maxInFlightRequestsPerGrant":1,"maxLiveGrants":8,"messageRoles":["system","user","assistant"],"purposes":["judge","optimizer"],"requestKinds":["request_inference_grant","release_inference_grant"],"tokenPrefix":"inference_","ttlMs":600000},"launcherSocketPath":"/run/daimon-engine-broker/launcher.sock","mcpFacade":{"host":"127.0.0.1","path":"/mcp","port":43124},"nativeAbiVersion":2,"nativeExecutablePath":"/opt/daimon/bin/daimon-engine-broker","projectionVersion":"noopolis.daimon.grok-broker-projection.v3","providerProxy":{"host":"127.0.0.1","port":43123},"registrationPath":"/etc/daimon-engine-broker/registrations.bin","runRequestBudget":{"countsCredentialRetries":true,"fileName":"run-requests.json","includesInferenceGrants":true,"maximumField":"maxRunRequests","storeField":"runRequestStore"},"serviceConfigPath":"/etc/daimon-engine-broker/service.json","serviceConfigVersions":["noopolis.daimon.engine-broker-service.v1","noopolis.daimon.engine-broker-service.v2","noopolis.daimon.engine-broker-service.v3"],"slotPreflightVersion":"noopolis.daimon.grok-slot-preflight.v2","slotRouting":{"controlCaller":"organization","key":"slot","repeatedAgentIds":true,"resetRequest":"reset_slot","resetResponse":"slot_reset","slotEnvironment":"DAIMON_ENGINE_BROKER_SLOT","turnStoreDirectory":"slot-{slot}","workersMayControl":false},"turnLimits":{"bounds":{"maxRequests":[1,48],"maxTokens":[1,10000000],"timeoutMs":[1000,3600000]},"keys":["maxRequests","maxTokens","timeoutMs"],"limitReasons":["tokens","requests","timeout","none"],"maxInFlightRequests":1,"missingUsageEstimate":{"inputBytesPerToken":2,"outputTokens":4096},"requestUsageMaxTokens":500000,"tokenCeilingOvershoot":"at-most-one-request","v1Defaults":{"maxRequests":32,"maxTokens":300000,"timeoutMs":240000},"wakeMayOnlyLower":true},"turnRecordVersions":["noopolis.daimon.engine-broker-turn.v1","noopolis.daimon.engine-broker-turn.v2"],"turnStorePath":"/var/lib/spawnfile/daimon/grok-subscription-realm/turns","wakeLimitEnvironment":{"maxTokens":"DAIMON_ENGINE_WAKE_TOKEN_CEILING","timeoutMs":"DAIMON_ENGINE_WAKE_TIMEOUT_MS"},"worker":{"clockedConfigSha256":{"grok-4.5":{"high":"907d8967960c342b442d6d256b2585f3c85bd846a78578f596f0b605403d6d34","low":"3c4f3515b21c1bffb3fb770e2ecb4b918b6c43d5e77c1fd47e1e6698d1fedee7","medium":"9117e12b2f3f6fa045799592969cbaae1c2f3b1ea79330badac8655b9b41dd7a"},"grok-4.6":{"high":"203744f6f0dfc0bc80cfb896880497edfd9b3de4dcf33060e8ef4d2ef5f220a7","low":"751f1dcd04983e5e5883df5a8fefa04f201e187e5ef1f344d3bf83f950830432","medium":"f1406f93a8091eb0c09e07321254a9c783a89b0bbf5548d9613d53a8d087a517"},"grok-build":{"high":"6efddadc82c38020d3351829e54bb09fa456cd6d76ea1247902794d82a4a3ffa","low":"a8c63ba910623a0686930c27db0733f4a2588c7fca9dd7da25c3f18027720ab5","medium":"6afcd2b90c0d19794f8c0d22147a0f011946a92e613f89dae98b0ca729313101"}},"configSha256":{"grok-4.5":{"high":"c63c3387ce92d94ec3f690abfe98942afcd7c9e17ff84816bbe751f340ab251f","low":"8247127c3625ff7c5d8d527a53596b89ec6557a821ac46cfd00bd122b90daff6","medium":"59288cee61297bb8c002097061a48f77b09d310754187a253ee089f7172a9155"},"grok-4.6":{"high":"65b0212564fb74042b1503d293fb8d3620276033264c0efade2a539ca09218e3","low":"ab58499ac32678097c146479896f2b8a8e2b0e39aea22dc0a60b6227e370538e","medium":"df1a5cc84346e7f6bf6090492fbd19faaefb42953e3bb2e8c6cbc0572242403f"},"grok-build":{"high":"a23724e00d670caee185ba7690d2daa868173e53905cf446f5329666f01ab4e3","low":"fb343f2809903f26d21681470943235031f946e99085542fd89555eb7782cbb5","medium":"8a587ef75c90eab70d19b24583e60051d6fba9d90c558839fdbb15588b4cc656"}},"defaultModel":"grok-4.6","defaultReasoningEffort":"low","home":{"directory":{"group":"worker","mode":1017,"uid":0},"organizationRuntimeHome":{"group":"worker","mode":456,"owner":"organization"},"privateTmp":{"mode":448,"owner":"worker","relativeToWorkerHome":"tmp"},"readOnlyFiles":{"gid":0,"mode":292,"names":["config.toml","managed_config.toml","requirements.toml","sandbox.toml","trusted_folders.toml"],"uid":0},"sandboxEvents":{"group":"broker","mode":416,"owner":"worker","relativePath":"sessions/sandbox-events.jsonl"},"sessionsDirectory":{"group":"worker","mode":1017,"relativePath":"sessions","uid":0},"sharedTmp":{"maxGroupExclusive":2200,"mode":1020,"otherMode":4,"paths":["/tmp","/var/tmp"],"uid":0},"spillDirectory":{"fileMode":416,"group":"worker","mode":1512,"owner":"organization","relativeToRuntimeHome":"tool-output"}},"maxTurns":48,"modelId":"daimon-broker-grok","models":["grok-4.6","grok-4.5","grok-build"],"reasoningEfforts":["low","medium","high"],"systemPromptSha256":"2c31c0085a54a4efbf9c0cf0b8124c56e47f38691b7f0c7fa233a74abaa8ddf8","taskClock":{"directoryMode":493,"directoryPath":"/run/daimon-engine-broker/task-clock/slot-0","fileMode":420,"ownerUid":2100,"preloadPath":"/usr/local/lib/daimon/libfaketime.so.1","rootDirectoryPath":"/run/daimon-engine-broker/task-clock","slotDirectoryPattern":"/run/daimon-engine-broker/task-clock/slot-{slot}","slotFilePattern":"/run/daimon-engine-broker/task-clock/slot-{slot}/faketime","timestampFilePath":"/run/daimon-engine-broker/task-clock/slot-0/faketime","version":"noopolis.daimon.grok-broker-task-clock.v2","workerView":"own-directory-visible-sibling-directories-denied"},"toolIds":["run_terminal_cmd","read_file","grep","list_dir","search_tool","use_tool"],"visibleTools":["grep","list_dir","read_file","run_terminal_command","search_tool","use_tool"]}},"grokSubscriptionRealm":{"agentCredentialRelativePath":".grok/auth.json","bootstrapMountPath":"/var/lib/spawnfile/daimon/grok-bootstrap-auth","bootstrapSourceSlot":"grok-auth","directoryMode":448,"durableMountPath":"/var/lib/spawnfile/daimon/grok-subscription-realm","fileMode":384,"maxCredentialBytes":65536},"healthResponseSchema":{"additionalProperties":false,"properties":{"agents":{"items":{"additionalProperties":false,"properties":{"agentId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"state":{"enum":["starting","running","stopping","stopped","idle","failed"]}},"required":["agentId","state"],"type":"object"},"maxItems":32,"type":"array"},"state":{"enum":["starting","running","stopping","stopped"]},"version":{"const":"noopolis.daimon.organization-runtime-health.v1"}},"required":["version","state","agents"],"type":"object"},"operatorDrain":{"authorization":"control-bearer","blockedReason":"operator_stop","drainRoute":"POST /v2/drain","queuedWakes":"retained","response":"workAvailabilityResponseSchema","resumeClearsOperatorStop":false,"resumeRoute":"POST /v2/resume","runningTurns":"finish","scope":"process"},"organizationRuntimeConfigSchema":{"$id":"noopolis.daimon.organization-runtime.v1","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"agents":{"items":{"additionalProperties":false,"properties":{"attention":{"additionalProperties":false,"properties":{"maxBatchBytes":{"maximum":12000,"minimum":1024,"type":"integer"},"maxBatchMessages":{"maximum":32,"minimum":1,"type":"integer"},"maxExecutions":{"maximum":9007199254740991,"minimum":1,"type":"integer"},"maxTokens":{"maximum":9007199254740991,"minimum":1,"type":"integer"}},"type":"object"},"engine":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"grok"}}},"then":{"dependentRequired":{"model":["reasoningEffort"],"reasoningEffort":["model"]},"properties":{"codexSandbox":false,"model":{"enum":["grok-4.6","grok-4.5","grok-build"]},"reasoningEffort":{"enum":["low","medium","high"]}}}},{"if":{"properties":{"kind":{"const":"agy"}}},"then":{"properties":{"codexSandbox":false,"model":false,"reasoningEffort":false}}}],"properties":{"codexSandbox":{"additionalProperties":false,"properties":{"mode":{"const":"workspace-write"},"networkAccess":{"const":false},"webSearch":{"const":"disabled"}},"required":["mode","networkAccess","webSearch"],"type":"object"},"kind":{"enum":["codex","grok","agy"]},"model":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"reasoningEffort":{"enum":["none","minimal","low","medium","high","xhigh","max","ultra","persistent"]}},"required":["kind"],"type":"object"},"id":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"instructions":{"maxLength":16384,"minLength":1,"pattern":"\\S","type":"string"},"mcp":{"items":{"additionalProperties":false,"properties":{"args":{"items":{"maxLength":4096,"type":"string"},"maxItems":32,"type":"array"},"authSecretEnv":{"pattern":"^[A-Za-z_][A-Za-z0-9_]*$","type":"string"},"command":{"pattern":"^/","type":"string"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"name":{"maxLength":4096,"minLength":1,"type":"string"},"tools":{"items":{"maxLength":4096,"minLength":1,"type":"string"},"maxItems":32,"minItems":1,"type":"array","uniqueItems":true},"transport":{"enum":["stdio","sse","streamable_http"]},"url":{"type":"string"}},"required":["name","transport","args","env","tools"],"type":"object"},"maxItems":8,"type":"array"},"memory":{"additionalProperties":false,"properties":{"runtimeHomePath":{"maxLength":4096,"pattern":"^/","type":"string"},"source":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"tokenBudget":{"maximum":1000000,"minimum":1,"type":"integer"}},"required":["runtimeHomePath"],"type":"object"},"moltnet":{"additionalProperties":false,"properties":{"cliPath":{"pattern":"^/","type":"string"},"configPath":{"pattern":"^/","type":"string"},"networks":{"items":{"additionalProperties":false,"properties":{"dms":{"type":"boolean"},"id":{"minLength":1,"type":"string"},"rooms":{"items":{"minLength":1,"type":"string"},"type":"array","uniqueItems":true}},"required":["id","rooms","dms"],"type":"object"},"maxItems":16,"type":"array"}},"required":["cliPath","configPath","networks"],"type":"object"},"name":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"recording":{"additionalProperties":false,"properties":{"directory":{"maxLength":4096,"pattern":"^/[^\\u0000]*$","type":"string"},"keepMs":{"maximum":9007199254740991,"minimum":1,"type":"integer"},"snapshots":{"items":{"additionalProperties":false,"properties":{"id":{"pattern":"^[A-Za-z0-9._-]{1,128}$","type":"string"},"path":{"maxLength":4096,"pattern":"^/[^\\u0000]*$","type":"string"}},"required":["id","path"],"type":"object"},"maxItems":16,"type":"array","uniqueItems":true}},"required":["directory","keepMs","snapshots"],"type":"object"},"runtimeHomePath":{"maxLength":4096,"pattern":"^/","type":"string"},"workspacePath":{"maxLength":4096,"pattern":"^/","type":"string"}},"required":["id","name","instructions","workspacePath","runtimeHomePath","engine"],"type":"object"},"maxItems":32,"minItems":1,"type":"array"},"host":{"additionalProperties":false,"properties":{"bindHost":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"controlTokenEnv":{"maxLength":4096,"pattern":"^[A-Za-z_][A-Za-z0-9_]*$","type":"string"},"port":{"maximum":65535,"minimum":1,"type":"integer"}},"required":["bindHost","port","controlTokenEnv"],"type":"object"},"version":{"const":"noopolis.daimon.organization-runtime.v1"}},"required":["version","host","agents"],"type":"object"},"organizationRuntimeConfigV2Schema":{"$id":"noopolis.daimon.organization-runtime.v2","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"agents":{"items":{"additionalProperties":false,"properties":{"attention":{"additionalProperties":false,"properties":{"maxBatchBytes":{"maximum":12000,"minimum":1024,"type":"integer"},"maxBatchMessages":{"maximum":32,"minimum":1,"type":"integer"},"maxExecutions":{"maximum":9007199254740991,"minimum":1,"type":"integer"},"maxTokens":{"maximum":9007199254740991,"minimum":1,"type":"integer"}},"type":"object"},"engine":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"grok"}}},"then":{"dependentRequired":{"model":["reasoningEffort"],"reasoningEffort":["model"]},"properties":{"codexSandbox":false,"model":{"enum":["grok-4.6","grok-4.5","grok-build"]},"reasoningEffort":{"enum":["low","medium","high"]}}}},{"if":{"properties":{"kind":{"const":"agy"}}},"then":{"properties":{"codexSandbox":false,"model":false,"reasoningEffort":false}}}],"properties":{"codexSandbox":{"additionalProperties":false,"properties":{"mode":{"const":"workspace-write"},"networkAccess":{"const":false},"webSearch":{"const":"disabled"}},"required":["mode","networkAccess","webSearch"],"type":"object"},"kind":{"enum":["codex","grok","agy"]},"model":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"reasoningEffort":{"enum":["none","minimal","low","medium","high","xhigh","max","ultra","persistent"]}},"required":["kind"],"type":"object"},"id":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"instructions":{"maxLength":16384,"minLength":1,"pattern":"\\S","type":"string"},"mcp":{"items":{"additionalProperties":false,"properties":{"args":{"items":{"maxLength":4096,"type":"string"},"maxItems":32,"type":"array"},"authSecretEnv":{"pattern":"^[A-Za-z_][A-Za-z0-9_]*$","type":"string"},"command":{"pattern":"^/","type":"string"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"name":{"maxLength":4096,"minLength":1,"type":"string"},"tools":{"items":{"maxLength":4096,"minLength":1,"type":"string"},"maxItems":32,"minItems":1,"type":"array","uniqueItems":true},"transport":{"enum":["stdio","sse","streamable_http"]},"url":{"type":"string"}},"required":["name","transport","args","env","tools"],"type":"object"},"maxItems":8,"type":"array"},"memory":{"additionalProperties":false,"properties":{"runtimeHomePath":{"maxLength":4096,"pattern":"^/","type":"string"},"source":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"tokenBudget":{"maximum":1000000,"minimum":1,"type":"integer"}},"required":["runtimeHomePath"],"type":"object"},"moltnet":{"additionalProperties":false,"properties":{"cliPath":{"pattern":"^/","type":"string"},"configPath":{"pattern":"^/","type":"string"},"networks":{"items":{"additionalProperties":false,"properties":{"dms":{"type":"boolean"},"id":{"minLength":1,"type":"string"},"rooms":{"items":{"minLength":1,"type":"string"},"type":"array","uniqueItems":true}},"required":["id","rooms","dms"],"type":"object"},"maxItems":16,"type":"array"}},"required":["cliPath","configPath","networks"],"type":"object"},"name":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"recording":{"additionalProperties":false,"properties":{"directory":{"maxLength":4096,"pattern":"^/[^\\u0000]*$","type":"string"},"keepMs":{"maximum":9007199254740991,"minimum":1,"type":"integer"},"snapshots":{"items":{"additionalProperties":false,"properties":{"id":{"pattern":"^[A-Za-z0-9._-]{1,128}$","type":"string"},"path":{"maxLength":4096,"pattern":"^/[^\\u0000]*$","type":"string"}},"required":["id","path"],"type":"object"},"maxItems":16,"type":"array","uniqueItems":true}},"required":["directory","keepMs","snapshots"],"type":"object"},"runtimeHomePath":{"maxLength":4096,"pattern":"^/","type":"string"},"schedule":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"disabled"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"interval_ms":{"maximum":31536000000,"minimum":1,"type":"integer"},"jitter_seconds":{"maximum":3600,"minimum":0,"type":"integer"},"kind":{"const":"every"},"prompt":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"}},"required":["kind","interval_ms","prompt"],"type":"object"},{"additionalProperties":false,"properties":{"cron":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"jitter_seconds":{"maximum":3600,"minimum":0,"type":"integer"},"kind":{"const":"cron"},"prompt":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"timezone":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"}},"required":["kind","cron","timezone","prompt"],"type":"object"}]},"workspacePath":{"maxLength":4096,"pattern":"^/","type":"string"}},"required":["id","name","instructions","workspacePath","runtimeHomePath","engine","schedule"],"type":"object"},"maxItems":32,"minItems":1,"type":"array"},"host":{"additionalProperties":false,"properties":{"bindHost":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"controlTokenEnv":{"maxLength":4096,"pattern":"^[A-Za-z_][A-Za-z0-9_]*$","type":"string"},"port":{"maximum":65535,"minimum":1,"type":"integer"}},"required":["bindHost","port","controlTokenEnv"],"type":"object"},"version":{"const":"noopolis.daimon.organization-runtime.v2"}},"required":["version","host","agents"],"type":"object"},"supportedEngineKinds":["agy","codex","grok"],"version":"noopolis.daimon.runtime-contract-manifest.v5","wakeAcceptanceTypes":["manual","message","schedule","external"],"wakeMoments":{"captureDeadlineMs":60000,"completionSuffix":".complete","configField":"agents[].recording","finalizationDeadlineMs":1000,"maxManifestBytes":134217728,"maxRowBytes":4194304,"maxSnapshotEntries":200000,"maxSnapshotRoots":16,"rowsFile":"wake-moments.jsonl","snapshotsDirectory":"snapshots","version":"noopolis.daimon.wake-moment.v1"},"wakeRequestSchema":{"additionalProperties":false,"properties":{"agentId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"event":{"additionalProperties":false,"properties":{"id":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"kind":{"enum":["manual","message","schedule","external"]},"occurredAt":{"format":"date-time","pattern":"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$","type":"string"},"text":{"maxLength":4096,"type":"string"},"version":{"const":"noopolis.daimon.wake.v1"}},"required":["version","id","kind","text","occurredAt"],"type":"object"}},"required":["agentId","event"],"type":"object"},"wakeResultSchema":{"oneOf":[{"additionalProperties":false,"properties":{"agentId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"durationMs":{"minimum":0,"type":"integer"},"status":{"const":"completed"},"text":{"maxLength":4096,"type":"string"},"version":{"const":"noopolis.daimon.wake-result.v1"},"wakeId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"}},"required":["version","status","agentId","wakeId","text","durationMs"],"type":"object"},{"additionalProperties":false,"properties":{"agentId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"code":{"enum":["unauthorized","unknown_agent","queue_full","durable_inbox_required"]},"status":{"const":"rejected"},"version":{"const":"noopolis.daimon.wake-result.v1"},"wakeId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"}},"required":["version","status","agentId","wakeId","code"],"type":"object"},{"additionalProperties":false,"properties":{"agentId":{"maxLength":4096,"type":"string"},"code":{"const":"invalid_request"},"status":{"const":"rejected"},"version":{"const":"noopolis.daimon.wake-result.v1"},"wakeId":{"maxLength":4096,"type":"string"}},"required":["version","status","agentId","wakeId","code"],"type":"object"},{"additionalProperties":false,"properties":{"agentId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"code":{"enum":["host_stopping","host_stopped","queued_wake_stopped","active_wake_aborted"]},"status":{"const":"stopped"},"version":{"const":"noopolis.daimon.wake-result.v1"},"wakeId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"}},"required":["version","status","agentId","wakeId","code"],"type":"object"},{"additionalProperties":false,"properties":{"agentId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"code":{"const":"engine_failed"},"status":{"const":"failed"},"version":{"const":"noopolis.daimon.wake-result.v1"},"wakeId":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"}},"required":["version","status","agentId","wakeId","code"],"type":"object"}]},"workAvailabilityResponseSchema":{"additionalProperties":false,"properties":{"agents":{"items":{"additionalProperties":false,"properties":{"agent_id":{"type":"string"},"budget":{"additionalProperties":false,"properties":{"agent_executions_remaining":{"minimum":0,"type":"integer"},"agent_executions_used":{"minimum":0,"type":"integer"},"agent_tokens_remaining":{"minimum":0,"type":"integer"},"agent_tokens_used":{"minimum":0,"type":"integer"},"armed":{"type":"boolean"},"epoch":{"type":"string"},"executions_remaining":{"minimum":0,"type":"integer"},"executions_used":{"minimum":0,"type":"integer"},"reason":{"type":"string"},"state":{"enum":["available","paused","stopped"]},"tokens_remaining":{"minimum":0,"type":"integer"},"tokens_used":{"minimum":0,"type":"integer"}},"required":["armed","epoch","state","executions_used","executions_remaining","tokens_used","tokens_remaining","agent_executions_used","agent_tokens_used"],"type":"object"},"deferred":{"minimum":0,"type":"integer"},"error":{"type":"string"},"pending":{"minimum":0,"type":"integer"},"running":{"type":"boolean"}},"required":["agent_id","pending","running","deferred","budget"],"type":"object"},"maxItems":32,"type":"array"},"drain":{"additionalProperties":false,"properties":{"since":{"type":"string"},"state":{"enum":["draining","drained"]}},"required":["state","since"],"type":"object"},"state":{"enum":["running","paused","stopped"]},"version":{"const":"noopolis.daimon.work-availability.v1"}},"required":["version","state","agents"],"type":"object"},"workBlockedSchema":{"additionalProperties":false,"properties":{"reason":{"enum":["operator_stop","ledger_unavailable","host_stopping","host_stopped","queue_full"]},"retry_after_ms":{"maximum":300000,"minimum":1000,"type":"integer"},"version":{"const":"noopolis.daimon.work-blocked.v1"}},"required":["version","reason","retry_after_ms"],"type":"object"}} diff --git a/src/runtime/daimon/contract-manifest.sha256 b/src/runtime/daimon/contract-manifest.sha256 index 21b41675..e0f8bcdf 100644 --- a/src/runtime/daimon/contract-manifest.sha256 +++ b/src/runtime/daimon/contract-manifest.sha256 @@ -1 +1 @@ -sha256:e7b457b2ca201cc686e66e841bb395780187dbd1c349b22be8973f0b346c6b4a +sha256:e39551277c6727c8807b1247e759f57addcee3f17f1405ebc927179647d64de6 diff --git a/src/runtime/daimon/contractManifest.test.ts b/src/runtime/daimon/contractManifest.test.ts index 137e6c12..79355693 100644 --- a/src/runtime/daimon/contractManifest.test.ts +++ b/src/runtime/daimon/contractManifest.test.ts @@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; import { removeDirectory } from "../../filesystem/index.js"; +import { resolveRuntimeInstallSelection } from "../install.js"; import { assertDaimonRuntimeHome, DAIMON_CONTRACT_MANIFEST_DIGEST_FILE, @@ -87,6 +88,16 @@ afterEach(async () => { }); describe("Daimon contract manifest", () => { + it("pairs the default runtime image with the actual vendored contract bytes", async () => { + const selection = await resolveRuntimeInstallSelection("daimon"); + const vendored = await readVerifiedDaimonContractManifest(path.dirname(fileURLToPath(import.meta.url))); + expect(selection).toMatchObject({ + kind: "container_image", + contractManifestSha256: vendored.digest + }); + expect(vendored.digest).toBe(DAIMON_CONTRACT_MANIFEST_SHA256); + }); + it("supports wake recording only when its exact section and consumed field appear together", () => { expect(daimonSupportsWakeMoments(parseDaimonContractManifest(manifest()))).toBe(false); const capable = { ...manifest(), wakeMoments: DAIMON_WAKE_MOMENTS, diff --git a/src/runtime/daimon/contractManifest.ts b/src/runtime/daimon/contractManifest.ts index cc0228b6..f1c2b63d 100644 --- a/src/runtime/daimon/contractManifest.ts +++ b/src/runtime/daimon/contractManifest.ts @@ -6,9 +6,9 @@ import { SpawnfileError } from "../../shared/index.js"; import { DAIMON_WAKE_MOMENTS, isDaimonWakeMoments } from "./wakeMoments.js"; export const DAIMON_CONTRACT_MANIFEST_VERSION = - "noopolis.daimon.runtime-contract-manifest.v4" as const; + "noopolis.daimon.runtime-contract-manifest.v5" as const; export const DAIMON_CONTRACT_MANIFEST_SHA256 = - "sha256:e7b457b2ca201cc686e66e841bb395780187dbd1c349b22be8973f0b346c6b4a" as const; + "sha256:e39551277c6727c8807b1247e759f57addcee3f17f1405ebc927179647d64de6" as const; export const DAIMON_CONTRACT_MANIFEST_FILE = "contract-manifest.json"; export const DAIMON_CONTRACT_MANIFEST_DIGEST_FILE = "contract-manifest.sha256"; export const DAIMON_RUNTIME_HOME_ROOT = "/var/lib/spawnfile/instances/daimon"; @@ -77,19 +77,11 @@ export const DAIMON_GROK_ENGINE_BROKER = { "grok-build": { low: "fb343f2809903f26d21681470943235031f946e99085542fd89555eb7782cbb5", medium: "8a587ef75c90eab70d19b24583e60051d6fba9d90c558839fdbb15588b4cc656", high: "a23724e00d670caee185ba7690d2daa868173e53905cf446f5329666f01ab4e3" } }, clockedConfigSha256: { - "grok-4.6": { low: "634ec9ce49c9e8d508b574e7b183678330359cfed63738df209a38963c730dab", medium: "dacd7e1c2de68d9edd2250b436d76e67046d071f163d56b7c291a315f2342e8c", high: "7aac64db19a2f59967bbbe1ed7a8b748985927f0f15cad847eb8cd20535eb70b" }, - "grok-4.5": { low: "831a68af36566ae24c1a8301b3fa866167fd173bf22334c2caf897c6d41c8a15", medium: "f517dfefb0b6ee6252d20bd81c057afca6d6f382c92c686d0b83e91a4a77ac07", high: "bf9d36286ec81fd5d37a726c5835d11349e8cc91bafe1428fef7171a3c509091" }, - "grok-build": { low: "22adfcb78a281e768f878996de8665e56ff698bf9c46bf06ce894ef9c21c2512", medium: "74c5362427c371e06bbf4ea5f3e10024fdb8ea468103b2206f7f4e873572932a", high: "127e592c0df2c43de51559ae7c90e607f59e2b1ccf5b8c0a082cd64e46ab7823" } - }, - taskClock: { - preloadPath: "/usr/local/lib/daimon/libfaketime.so.1", - timestampFilePath: "/run/daimon-engine-broker/task-clock/faketime", - directoryPath: "/run/daimon-engine-broker/task-clock", - directoryMode: 0o755, - fileMode: 0o644, - ownerUid: 2100, - maxRegistrations: 1 + "grok-4.5": {"high": "907d8967960c342b442d6d256b2585f3c85bd846a78578f596f0b605403d6d34", "low": "3c4f3515b21c1bffb3fb770e2ecb4b918b6c43d5e77c1fd47e1e6698d1fedee7", "medium": "9117e12b2f3f6fa045799592969cbaae1c2f3b1ea79330badac8655b9b41dd7a"}, + "grok-4.6": {"high": "203744f6f0dfc0bc80cfb896880497edfd9b3de4dcf33060e8ef4d2ef5f220a7", "low": "751f1dcd04983e5e5883df5a8fefa04f201e187e5ef1f344d3bf83f950830432", "medium": "f1406f93a8091eb0c09e07321254a9c783a89b0bbf5548d9613d53a8d087a517"}, + "grok-build": {"high": "6efddadc82c38020d3351829e54bb09fa456cd6d76ea1247902794d82a4a3ffa", "low": "a8c63ba910623a0686930c27db0733f4a2588c7fca9dd7da25c3f18027720ab5", "medium": "6afcd2b90c0d19794f8c0d22147a0f011946a92e613f89dae98b0ca729313101"}, }, + taskClock: {"directoryMode": 493, "directoryPath": "/run/daimon-engine-broker/task-clock/slot-0", "fileMode": 420, "ownerUid": 2100, "preloadPath": "/usr/local/lib/daimon/libfaketime.so.1", "rootDirectoryPath": "/run/daimon-engine-broker/task-clock", "slotDirectoryPattern": "/run/daimon-engine-broker/task-clock/slot-{slot}", "slotFilePattern": "/run/daimon-engine-broker/task-clock/slot-{slot}/faketime", "timestampFilePath": "/run/daimon-engine-broker/task-clock/slot-0/faketime", "version": "noopolis.daimon.grok-broker-task-clock.v2", "workerView": "own-directory-visible-sibling-directories-denied"}, home: { directory: { uid: 0, group: "worker", mode: 0o1771 }, sessionsDirectory: { relativePath: "sessions", uid: 0, group: "worker", mode: 0o1771 }, @@ -102,9 +94,11 @@ export const DAIMON_GROK_ENGINE_BROKER = { } }, bounds: { promptBytes: 65_536, capabilityBytes: 4_096, capabilityBundleBytes: 8_196, outputBytes: 262_144 }, - controlProtocolVersion: "noopolis.daimon.engine-broker.v3", + controlProtocolVersion: "noopolis.daimon.engine-broker.v4", turnRecordVersions: ["noopolis.daimon.engine-broker-turn.v1", "noopolis.daimon.engine-broker-turn.v2"], - serviceConfigVersions: ["noopolis.daimon.engine-broker-service.v1", "noopolis.daimon.engine-broker-service.v2"], + serviceConfigVersions: ["noopolis.daimon.engine-broker-service.v1", "noopolis.daimon.engine-broker-service.v2", "noopolis.daimon.engine-broker-service.v3"], + slotRouting: {"controlCaller": "organization", "key": "slot", "repeatedAgentIds": true, "resetRequest": "reset_slot", "resetResponse": "slot_reset", "slotEnvironment": "DAIMON_ENGINE_BROKER_SLOT", "turnStoreDirectory": "slot-{slot}", "workersMayControl": false}, + runRequestBudget: {"countsCredentialRetries": true, "fileName": "run-requests.json", "includesInferenceGrants": true, "maximumField": "maxRunRequests", "storeField": "runRequestStore"}, turnLimits: { keys: ["maxRequests", "maxTokens", "timeoutMs"], v1Defaults: { maxRequests: 32, maxTokens: 300_000, timeoutMs: 240_000 }, @@ -117,7 +111,7 @@ export const DAIMON_GROK_ENGINE_BROKER = { missingUsageEstimate: { inputBytesPerToken: 2, outputTokens: 4_096 } }, wakeLimitEnvironment: { timeoutMs: "DAIMON_ENGINE_WAKE_TIMEOUT_MS", maxTokens: "DAIMON_ENGINE_WAKE_TOKEN_CEILING" }, - projectionVersion: "noopolis.daimon.grok-broker-projection.v2", + projectionVersion: "noopolis.daimon.grok-broker-projection.v3", slotPreflightVersion: "noopolis.daimon.grok-slot-preflight.v2", inferenceGrants: { requestKinds: ["request_inference_grant", "release_inference_grant"], diff --git a/src/runtime/daimon/grokWorkerConfigBytes.ts b/src/runtime/daimon/grokWorkerConfigBytes.ts index 92289d4c..1278ed8e 100644 --- a/src/runtime/daimon/grokWorkerConfigBytes.ts +++ b/src/runtime/daimon/grokWorkerConfigBytes.ts @@ -20,22 +20,21 @@ export const DAIMON_GROK_WORKER_CONFIG_BYTES = { } } as const; -/** Daimon's additional clocked variant; production keeps the default bytes above. */ export const DAIMON_GROK_CLOCKED_WORKER_CONFIG_BYTES = { "grok-4.6": { - "low": "[cli]\nauto_update = false\nuse_leader = false\nshow_tips = false\n\n[features]\ntelemetry = false\ntitle_refresh = false\nsession_recap = false\nturn_summary = false\nrepo_status_in_system_prompt = false\ncodebase_indexing = false\nbackend_tools = false\nask_user_question = false\nimage_gen = false\nvideo_gen = false\nweb_fetch = false\ncampaigns = false\nmanaged_config = false\n\n[managed_mcps]\nenabled = false\n\n[skills]\ndisabled = [\"build-with-ai\", \"code-review\", \"create-skill\", \"create-workflow\", \"design\", \"docx\", \"execute-plan\", \"game-animation-frames\", \"game-asset-core\", \"game-character-consistency\", \"game-tilesets\", \"game-ui-icons\", \"imagine\", \"implement\", \"learn\", \"long-running-background-tasks\", \"pdf\", \"pptx\", \"pr-babysit\", \"resume-claude\", \"resume-codex\", \"resume-cursor\", \"review\", \"skill-design-principles\", \"statusline\"]\n\n[workflows]\nenabled = false\n\n[models]\ndefault = \"daimon-broker-grok\"\ndefault_reasoning_effort = \"low\"\nsession_summary = \"daimon-session-title-disabled\"\n\n[model.daimon-session-title-disabled]\nmodel = \"disabled\"\nbase_url = \"http://127.0.0.1:43123/v1\"\napi_key = \"session-title-disabled\"\nmax_retries = 0\nhidden = true\n\n[model.daimon-broker-grok]\nmodel = \"grok-4.6\"\nbase_url = \"http://127.0.0.1:43123/v1\"\nenv_key = \"DAIMON_PROVIDER_CAPABILITY\"\napi_backend = \"chat_completions\"\ncontext_window = 131072\nsupports_backend_search = false\nmax_retries = 0\n\n[[model.daimon-broker-grok.reasoning_efforts]]\nvalue = \"low\"\nlabel = \"Low\"\ndefault = true\n\n[mcp_servers.daimon]\nurl = \"http://127.0.0.1:43124/mcp\"\nbearer_token_env_var = \"DAIMON_MCP_CAPABILITY\"\n\n[shell_environment_policy]\ninherit=\"all\"\nignore_default_excludes=false\nexclude=[]\ninclude_only=[]\nset={\"LD_PRELOAD\"=\"/usr/local/lib/daimon/libfaketime.so.1\",\"FAKETIME_TIMESTAMP_FILE\"=\"/run/daimon-engine-broker/task-clock/faketime\",\"FAKETIME_DONT_FAKE_MONOTONIC\"=\"1\",\"LC_ALL\"=\"C\"}\n", - "medium": "[cli]\nauto_update = false\nuse_leader = false\nshow_tips = false\n\n[features]\ntelemetry = false\ntitle_refresh = false\nsession_recap = false\nturn_summary = false\nrepo_status_in_system_prompt = false\ncodebase_indexing = false\nbackend_tools = false\nask_user_question = false\nimage_gen = false\nvideo_gen = false\nweb_fetch = false\ncampaigns = false\nmanaged_config = false\n\n[managed_mcps]\nenabled = false\n\n[skills]\ndisabled = [\"build-with-ai\", \"code-review\", \"create-skill\", \"create-workflow\", \"design\", \"docx\", \"execute-plan\", \"game-animation-frames\", \"game-asset-core\", \"game-character-consistency\", \"game-tilesets\", \"game-ui-icons\", \"imagine\", \"implement\", \"learn\", \"long-running-background-tasks\", \"pdf\", \"pptx\", \"pr-babysit\", \"resume-claude\", \"resume-codex\", \"resume-cursor\", \"review\", \"skill-design-principles\", \"statusline\"]\n\n[workflows]\nenabled = false\n\n[models]\ndefault = \"daimon-broker-grok\"\ndefault_reasoning_effort = \"medium\"\nsession_summary = \"daimon-session-title-disabled\"\n\n[model.daimon-session-title-disabled]\nmodel = \"disabled\"\nbase_url = \"http://127.0.0.1:43123/v1\"\napi_key = \"session-title-disabled\"\nmax_retries = 0\nhidden = true\n\n[model.daimon-broker-grok]\nmodel = \"grok-4.6\"\nbase_url = \"http://127.0.0.1:43123/v1\"\nenv_key = \"DAIMON_PROVIDER_CAPABILITY\"\napi_backend = \"chat_completions\"\ncontext_window = 131072\nsupports_backend_search = false\nmax_retries = 0\n\n[[model.daimon-broker-grok.reasoning_efforts]]\nvalue = \"medium\"\nlabel = \"Medium\"\ndefault = true\n\n[mcp_servers.daimon]\nurl = \"http://127.0.0.1:43124/mcp\"\nbearer_token_env_var = \"DAIMON_MCP_CAPABILITY\"\n\n[shell_environment_policy]\ninherit=\"all\"\nignore_default_excludes=false\nexclude=[]\ninclude_only=[]\nset={\"LD_PRELOAD\"=\"/usr/local/lib/daimon/libfaketime.so.1\",\"FAKETIME_TIMESTAMP_FILE\"=\"/run/daimon-engine-broker/task-clock/faketime\",\"FAKETIME_DONT_FAKE_MONOTONIC\"=\"1\",\"LC_ALL\"=\"C\"}\n", - "high": "[cli]\nauto_update = false\nuse_leader = false\nshow_tips = false\n\n[features]\ntelemetry = false\ntitle_refresh = false\nsession_recap = false\nturn_summary = false\nrepo_status_in_system_prompt = false\ncodebase_indexing = false\nbackend_tools = false\nask_user_question = false\nimage_gen = false\nvideo_gen = false\nweb_fetch = false\ncampaigns = false\nmanaged_config = false\n\n[managed_mcps]\nenabled = false\n\n[skills]\ndisabled = [\"build-with-ai\", \"code-review\", \"create-skill\", \"create-workflow\", \"design\", \"docx\", \"execute-plan\", \"game-animation-frames\", \"game-asset-core\", \"game-character-consistency\", \"game-tilesets\", \"game-ui-icons\", \"imagine\", \"implement\", \"learn\", \"long-running-background-tasks\", \"pdf\", \"pptx\", \"pr-babysit\", \"resume-claude\", \"resume-codex\", \"resume-cursor\", \"review\", \"skill-design-principles\", \"statusline\"]\n\n[workflows]\nenabled = false\n\n[models]\ndefault = \"daimon-broker-grok\"\ndefault_reasoning_effort = \"high\"\nsession_summary = \"daimon-session-title-disabled\"\n\n[model.daimon-session-title-disabled]\nmodel = \"disabled\"\nbase_url = \"http://127.0.0.1:43123/v1\"\napi_key = \"session-title-disabled\"\nmax_retries = 0\nhidden = true\n\n[model.daimon-broker-grok]\nmodel = \"grok-4.6\"\nbase_url = \"http://127.0.0.1:43123/v1\"\nenv_key = \"DAIMON_PROVIDER_CAPABILITY\"\napi_backend = \"chat_completions\"\ncontext_window = 131072\nsupports_backend_search = false\nmax_retries = 0\n\n[[model.daimon-broker-grok.reasoning_efforts]]\nvalue = \"high\"\nlabel = \"High\"\ndefault = true\n\n[mcp_servers.daimon]\nurl = \"http://127.0.0.1:43124/mcp\"\nbearer_token_env_var = \"DAIMON_MCP_CAPABILITY\"\n\n[shell_environment_policy]\ninherit=\"all\"\nignore_default_excludes=false\nexclude=[]\ninclude_only=[]\nset={\"LD_PRELOAD\"=\"/usr/local/lib/daimon/libfaketime.so.1\",\"FAKETIME_TIMESTAMP_FILE\"=\"/run/daimon-engine-broker/task-clock/faketime\",\"FAKETIME_DONT_FAKE_MONOTONIC\"=\"1\",\"LC_ALL\"=\"C\"}\n" + "low": "[cli]\nauto_update = false\nuse_leader = false\nshow_tips = false\n\n[features]\ntelemetry = false\ntitle_refresh = false\nsession_recap = false\nturn_summary = false\nrepo_status_in_system_prompt = false\ncodebase_indexing = false\nbackend_tools = false\nask_user_question = false\nimage_gen = false\nvideo_gen = false\nweb_fetch = false\ncampaigns = false\nmanaged_config = false\n\n[managed_mcps]\nenabled = false\n\n[skills]\ndisabled = [\"build-with-ai\", \"code-review\", \"create-skill\", \"create-workflow\", \"design\", \"docx\", \"execute-plan\", \"game-animation-frames\", \"game-asset-core\", \"game-character-consistency\", \"game-tilesets\", \"game-ui-icons\", \"imagine\", \"implement\", \"learn\", \"long-running-background-tasks\", \"pdf\", \"pptx\", \"pr-babysit\", \"resume-claude\", \"resume-codex\", \"resume-cursor\", \"review\", \"skill-design-principles\", \"statusline\"]\n\n[workflows]\nenabled = false\n\n[models]\ndefault = \"daimon-broker-grok\"\ndefault_reasoning_effort = \"low\"\nsession_summary = \"daimon-session-title-disabled\"\n\n[model.daimon-session-title-disabled]\nmodel = \"disabled\"\nbase_url = \"http://127.0.0.1:43123/v1\"\napi_key = \"session-title-disabled\"\nmax_retries = 0\nhidden = true\n\n[model.daimon-broker-grok]\nmodel = \"grok-4.6\"\nbase_url = \"http://127.0.0.1:43123/v1\"\nenv_key = \"DAIMON_PROVIDER_CAPABILITY\"\napi_backend = \"chat_completions\"\ncontext_window = 131072\nsupports_backend_search = false\nmax_retries = 0\n\n[[model.daimon-broker-grok.reasoning_efforts]]\nvalue = \"low\"\nlabel = \"Low\"\ndefault = true\n\n[mcp_servers.daimon]\nurl = \"http://127.0.0.1:43124/mcp\"\nbearer_token_env_var = \"DAIMON_MCP_CAPABILITY\"\n\n[shell_environment_policy]\ninherit=\"all\"\nignore_default_excludes=false\nexclude=[]\ninclude_only=[]\nset={\"LD_PRELOAD\"=\"/usr/local/lib/daimon/libfaketime.so.1\",\"FAKETIME_TIMESTAMP_FILE\"=\"/run/daimon-engine-broker/task-clock/slot-0/faketime\",\"FAKETIME_DONT_FAKE_MONOTONIC\"=\"1\",\"LC_ALL\"=\"C\"}\n", + "medium": "[cli]\nauto_update = false\nuse_leader = false\nshow_tips = false\n\n[features]\ntelemetry = false\ntitle_refresh = false\nsession_recap = false\nturn_summary = false\nrepo_status_in_system_prompt = false\ncodebase_indexing = false\nbackend_tools = false\nask_user_question = false\nimage_gen = false\nvideo_gen = false\nweb_fetch = false\ncampaigns = false\nmanaged_config = false\n\n[managed_mcps]\nenabled = false\n\n[skills]\ndisabled = [\"build-with-ai\", \"code-review\", \"create-skill\", \"create-workflow\", \"design\", \"docx\", \"execute-plan\", \"game-animation-frames\", \"game-asset-core\", \"game-character-consistency\", \"game-tilesets\", \"game-ui-icons\", \"imagine\", \"implement\", \"learn\", \"long-running-background-tasks\", \"pdf\", \"pptx\", \"pr-babysit\", \"resume-claude\", \"resume-codex\", \"resume-cursor\", \"review\", \"skill-design-principles\", \"statusline\"]\n\n[workflows]\nenabled = false\n\n[models]\ndefault = \"daimon-broker-grok\"\ndefault_reasoning_effort = \"medium\"\nsession_summary = \"daimon-session-title-disabled\"\n\n[model.daimon-session-title-disabled]\nmodel = \"disabled\"\nbase_url = \"http://127.0.0.1:43123/v1\"\napi_key = \"session-title-disabled\"\nmax_retries = 0\nhidden = true\n\n[model.daimon-broker-grok]\nmodel = \"grok-4.6\"\nbase_url = \"http://127.0.0.1:43123/v1\"\nenv_key = \"DAIMON_PROVIDER_CAPABILITY\"\napi_backend = \"chat_completions\"\ncontext_window = 131072\nsupports_backend_search = false\nmax_retries = 0\n\n[[model.daimon-broker-grok.reasoning_efforts]]\nvalue = \"medium\"\nlabel = \"Medium\"\ndefault = true\n\n[mcp_servers.daimon]\nurl = \"http://127.0.0.1:43124/mcp\"\nbearer_token_env_var = \"DAIMON_MCP_CAPABILITY\"\n\n[shell_environment_policy]\ninherit=\"all\"\nignore_default_excludes=false\nexclude=[]\ninclude_only=[]\nset={\"LD_PRELOAD\"=\"/usr/local/lib/daimon/libfaketime.so.1\",\"FAKETIME_TIMESTAMP_FILE\"=\"/run/daimon-engine-broker/task-clock/slot-0/faketime\",\"FAKETIME_DONT_FAKE_MONOTONIC\"=\"1\",\"LC_ALL\"=\"C\"}\n", + "high": "[cli]\nauto_update = false\nuse_leader = false\nshow_tips = false\n\n[features]\ntelemetry = false\ntitle_refresh = false\nsession_recap = false\nturn_summary = false\nrepo_status_in_system_prompt = false\ncodebase_indexing = false\nbackend_tools = false\nask_user_question = false\nimage_gen = false\nvideo_gen = false\nweb_fetch = false\ncampaigns = false\nmanaged_config = false\n\n[managed_mcps]\nenabled = false\n\n[skills]\ndisabled = [\"build-with-ai\", \"code-review\", \"create-skill\", \"create-workflow\", \"design\", \"docx\", \"execute-plan\", \"game-animation-frames\", \"game-asset-core\", \"game-character-consistency\", \"game-tilesets\", \"game-ui-icons\", \"imagine\", \"implement\", \"learn\", \"long-running-background-tasks\", \"pdf\", \"pptx\", \"pr-babysit\", \"resume-claude\", \"resume-codex\", \"resume-cursor\", \"review\", \"skill-design-principles\", \"statusline\"]\n\n[workflows]\nenabled = false\n\n[models]\ndefault = \"daimon-broker-grok\"\ndefault_reasoning_effort = \"high\"\nsession_summary = \"daimon-session-title-disabled\"\n\n[model.daimon-session-title-disabled]\nmodel = \"disabled\"\nbase_url = \"http://127.0.0.1:43123/v1\"\napi_key = \"session-title-disabled\"\nmax_retries = 0\nhidden = true\n\n[model.daimon-broker-grok]\nmodel = \"grok-4.6\"\nbase_url = \"http://127.0.0.1:43123/v1\"\nenv_key = \"DAIMON_PROVIDER_CAPABILITY\"\napi_backend = \"chat_completions\"\ncontext_window = 131072\nsupports_backend_search = false\nmax_retries = 0\n\n[[model.daimon-broker-grok.reasoning_efforts]]\nvalue = \"high\"\nlabel = \"High\"\ndefault = true\n\n[mcp_servers.daimon]\nurl = \"http://127.0.0.1:43124/mcp\"\nbearer_token_env_var = \"DAIMON_MCP_CAPABILITY\"\n\n[shell_environment_policy]\ninherit=\"all\"\nignore_default_excludes=false\nexclude=[]\ninclude_only=[]\nset={\"LD_PRELOAD\"=\"/usr/local/lib/daimon/libfaketime.so.1\",\"FAKETIME_TIMESTAMP_FILE\"=\"/run/daimon-engine-broker/task-clock/slot-0/faketime\",\"FAKETIME_DONT_FAKE_MONOTONIC\"=\"1\",\"LC_ALL\"=\"C\"}\n" }, "grok-4.5": { - "low": "[cli]\nauto_update = false\nuse_leader = false\nshow_tips = false\n\n[features]\ntelemetry = false\ntitle_refresh = false\nsession_recap = false\nturn_summary = false\nrepo_status_in_system_prompt = false\ncodebase_indexing = false\nbackend_tools = false\nask_user_question = false\nimage_gen = false\nvideo_gen = false\nweb_fetch = false\ncampaigns = false\nmanaged_config = false\n\n[managed_mcps]\nenabled = false\n\n[skills]\ndisabled = [\"build-with-ai\", \"code-review\", \"create-skill\", \"create-workflow\", \"design\", \"docx\", \"execute-plan\", \"game-animation-frames\", \"game-asset-core\", \"game-character-consistency\", \"game-tilesets\", \"game-ui-icons\", \"imagine\", \"implement\", \"learn\", \"long-running-background-tasks\", \"pdf\", \"pptx\", \"pr-babysit\", \"resume-claude\", \"resume-codex\", \"resume-cursor\", \"review\", \"skill-design-principles\", \"statusline\"]\n\n[workflows]\nenabled = false\n\n[models]\ndefault = \"daimon-broker-grok\"\ndefault_reasoning_effort = \"low\"\nsession_summary = \"daimon-session-title-disabled\"\n\n[model.daimon-session-title-disabled]\nmodel = \"disabled\"\nbase_url = \"http://127.0.0.1:43123/v1\"\napi_key = \"session-title-disabled\"\nmax_retries = 0\nhidden = true\n\n[model.daimon-broker-grok]\nmodel = \"grok-4.5\"\nbase_url = \"http://127.0.0.1:43123/v1\"\nenv_key = \"DAIMON_PROVIDER_CAPABILITY\"\napi_backend = \"chat_completions\"\ncontext_window = 131072\nsupports_backend_search = false\nmax_retries = 0\n\n[[model.daimon-broker-grok.reasoning_efforts]]\nvalue = \"low\"\nlabel = \"Low\"\ndefault = true\n\n[mcp_servers.daimon]\nurl = \"http://127.0.0.1:43124/mcp\"\nbearer_token_env_var = \"DAIMON_MCP_CAPABILITY\"\n\n[shell_environment_policy]\ninherit=\"all\"\nignore_default_excludes=false\nexclude=[]\ninclude_only=[]\nset={\"LD_PRELOAD\"=\"/usr/local/lib/daimon/libfaketime.so.1\",\"FAKETIME_TIMESTAMP_FILE\"=\"/run/daimon-engine-broker/task-clock/faketime\",\"FAKETIME_DONT_FAKE_MONOTONIC\"=\"1\",\"LC_ALL\"=\"C\"}\n", - "medium": "[cli]\nauto_update = false\nuse_leader = false\nshow_tips = false\n\n[features]\ntelemetry = false\ntitle_refresh = false\nsession_recap = false\nturn_summary = false\nrepo_status_in_system_prompt = false\ncodebase_indexing = false\nbackend_tools = false\nask_user_question = false\nimage_gen = false\nvideo_gen = false\nweb_fetch = false\ncampaigns = false\nmanaged_config = false\n\n[managed_mcps]\nenabled = false\n\n[skills]\ndisabled = [\"build-with-ai\", \"code-review\", \"create-skill\", \"create-workflow\", \"design\", \"docx\", \"execute-plan\", \"game-animation-frames\", \"game-asset-core\", \"game-character-consistency\", \"game-tilesets\", \"game-ui-icons\", \"imagine\", \"implement\", \"learn\", \"long-running-background-tasks\", \"pdf\", \"pptx\", \"pr-babysit\", \"resume-claude\", \"resume-codex\", \"resume-cursor\", \"review\", \"skill-design-principles\", \"statusline\"]\n\n[workflows]\nenabled = false\n\n[models]\ndefault = \"daimon-broker-grok\"\ndefault_reasoning_effort = \"medium\"\nsession_summary = \"daimon-session-title-disabled\"\n\n[model.daimon-session-title-disabled]\nmodel = \"disabled\"\nbase_url = \"http://127.0.0.1:43123/v1\"\napi_key = \"session-title-disabled\"\nmax_retries = 0\nhidden = true\n\n[model.daimon-broker-grok]\nmodel = \"grok-4.5\"\nbase_url = \"http://127.0.0.1:43123/v1\"\nenv_key = \"DAIMON_PROVIDER_CAPABILITY\"\napi_backend = \"chat_completions\"\ncontext_window = 131072\nsupports_backend_search = false\nmax_retries = 0\n\n[[model.daimon-broker-grok.reasoning_efforts]]\nvalue = \"medium\"\nlabel = \"Medium\"\ndefault = true\n\n[mcp_servers.daimon]\nurl = \"http://127.0.0.1:43124/mcp\"\nbearer_token_env_var = \"DAIMON_MCP_CAPABILITY\"\n\n[shell_environment_policy]\ninherit=\"all\"\nignore_default_excludes=false\nexclude=[]\ninclude_only=[]\nset={\"LD_PRELOAD\"=\"/usr/local/lib/daimon/libfaketime.so.1\",\"FAKETIME_TIMESTAMP_FILE\"=\"/run/daimon-engine-broker/task-clock/faketime\",\"FAKETIME_DONT_FAKE_MONOTONIC\"=\"1\",\"LC_ALL\"=\"C\"}\n", - "high": "[cli]\nauto_update = false\nuse_leader = false\nshow_tips = false\n\n[features]\ntelemetry = false\ntitle_refresh = false\nsession_recap = false\nturn_summary = false\nrepo_status_in_system_prompt = false\ncodebase_indexing = false\nbackend_tools = false\nask_user_question = false\nimage_gen = false\nvideo_gen = false\nweb_fetch = false\ncampaigns = false\nmanaged_config = false\n\n[managed_mcps]\nenabled = false\n\n[skills]\ndisabled = [\"build-with-ai\", \"code-review\", \"create-skill\", \"create-workflow\", \"design\", \"docx\", \"execute-plan\", \"game-animation-frames\", \"game-asset-core\", \"game-character-consistency\", \"game-tilesets\", \"game-ui-icons\", \"imagine\", \"implement\", \"learn\", \"long-running-background-tasks\", \"pdf\", \"pptx\", \"pr-babysit\", \"resume-claude\", \"resume-codex\", \"resume-cursor\", \"review\", \"skill-design-principles\", \"statusline\"]\n\n[workflows]\nenabled = false\n\n[models]\ndefault = \"daimon-broker-grok\"\ndefault_reasoning_effort = \"high\"\nsession_summary = \"daimon-session-title-disabled\"\n\n[model.daimon-session-title-disabled]\nmodel = \"disabled\"\nbase_url = \"http://127.0.0.1:43123/v1\"\napi_key = \"session-title-disabled\"\nmax_retries = 0\nhidden = true\n\n[model.daimon-broker-grok]\nmodel = \"grok-4.5\"\nbase_url = \"http://127.0.0.1:43123/v1\"\nenv_key = \"DAIMON_PROVIDER_CAPABILITY\"\napi_backend = \"chat_completions\"\ncontext_window = 131072\nsupports_backend_search = false\nmax_retries = 0\n\n[[model.daimon-broker-grok.reasoning_efforts]]\nvalue = \"high\"\nlabel = \"High\"\ndefault = true\n\n[mcp_servers.daimon]\nurl = \"http://127.0.0.1:43124/mcp\"\nbearer_token_env_var = \"DAIMON_MCP_CAPABILITY\"\n\n[shell_environment_policy]\ninherit=\"all\"\nignore_default_excludes=false\nexclude=[]\ninclude_only=[]\nset={\"LD_PRELOAD\"=\"/usr/local/lib/daimon/libfaketime.so.1\",\"FAKETIME_TIMESTAMP_FILE\"=\"/run/daimon-engine-broker/task-clock/faketime\",\"FAKETIME_DONT_FAKE_MONOTONIC\"=\"1\",\"LC_ALL\"=\"C\"}\n" + "low": "[cli]\nauto_update = false\nuse_leader = false\nshow_tips = false\n\n[features]\ntelemetry = false\ntitle_refresh = false\nsession_recap = false\nturn_summary = false\nrepo_status_in_system_prompt = false\ncodebase_indexing = false\nbackend_tools = false\nask_user_question = false\nimage_gen = false\nvideo_gen = false\nweb_fetch = false\ncampaigns = false\nmanaged_config = false\n\n[managed_mcps]\nenabled = false\n\n[skills]\ndisabled = [\"build-with-ai\", \"code-review\", \"create-skill\", \"create-workflow\", \"design\", \"docx\", \"execute-plan\", \"game-animation-frames\", \"game-asset-core\", \"game-character-consistency\", \"game-tilesets\", \"game-ui-icons\", \"imagine\", \"implement\", \"learn\", \"long-running-background-tasks\", \"pdf\", \"pptx\", \"pr-babysit\", \"resume-claude\", \"resume-codex\", \"resume-cursor\", \"review\", \"skill-design-principles\", \"statusline\"]\n\n[workflows]\nenabled = false\n\n[models]\ndefault = \"daimon-broker-grok\"\ndefault_reasoning_effort = \"low\"\nsession_summary = \"daimon-session-title-disabled\"\n\n[model.daimon-session-title-disabled]\nmodel = \"disabled\"\nbase_url = \"http://127.0.0.1:43123/v1\"\napi_key = \"session-title-disabled\"\nmax_retries = 0\nhidden = true\n\n[model.daimon-broker-grok]\nmodel = \"grok-4.5\"\nbase_url = \"http://127.0.0.1:43123/v1\"\nenv_key = \"DAIMON_PROVIDER_CAPABILITY\"\napi_backend = \"chat_completions\"\ncontext_window = 131072\nsupports_backend_search = false\nmax_retries = 0\n\n[[model.daimon-broker-grok.reasoning_efforts]]\nvalue = \"low\"\nlabel = \"Low\"\ndefault = true\n\n[mcp_servers.daimon]\nurl = \"http://127.0.0.1:43124/mcp\"\nbearer_token_env_var = \"DAIMON_MCP_CAPABILITY\"\n\n[shell_environment_policy]\ninherit=\"all\"\nignore_default_excludes=false\nexclude=[]\ninclude_only=[]\nset={\"LD_PRELOAD\"=\"/usr/local/lib/daimon/libfaketime.so.1\",\"FAKETIME_TIMESTAMP_FILE\"=\"/run/daimon-engine-broker/task-clock/slot-0/faketime\",\"FAKETIME_DONT_FAKE_MONOTONIC\"=\"1\",\"LC_ALL\"=\"C\"}\n", + "medium": "[cli]\nauto_update = false\nuse_leader = false\nshow_tips = false\n\n[features]\ntelemetry = false\ntitle_refresh = false\nsession_recap = false\nturn_summary = false\nrepo_status_in_system_prompt = false\ncodebase_indexing = false\nbackend_tools = false\nask_user_question = false\nimage_gen = false\nvideo_gen = false\nweb_fetch = false\ncampaigns = false\nmanaged_config = false\n\n[managed_mcps]\nenabled = false\n\n[skills]\ndisabled = [\"build-with-ai\", \"code-review\", \"create-skill\", \"create-workflow\", \"design\", \"docx\", \"execute-plan\", \"game-animation-frames\", \"game-asset-core\", \"game-character-consistency\", \"game-tilesets\", \"game-ui-icons\", \"imagine\", \"implement\", \"learn\", \"long-running-background-tasks\", \"pdf\", \"pptx\", \"pr-babysit\", \"resume-claude\", \"resume-codex\", \"resume-cursor\", \"review\", \"skill-design-principles\", \"statusline\"]\n\n[workflows]\nenabled = false\n\n[models]\ndefault = \"daimon-broker-grok\"\ndefault_reasoning_effort = \"medium\"\nsession_summary = \"daimon-session-title-disabled\"\n\n[model.daimon-session-title-disabled]\nmodel = \"disabled\"\nbase_url = \"http://127.0.0.1:43123/v1\"\napi_key = \"session-title-disabled\"\nmax_retries = 0\nhidden = true\n\n[model.daimon-broker-grok]\nmodel = \"grok-4.5\"\nbase_url = \"http://127.0.0.1:43123/v1\"\nenv_key = \"DAIMON_PROVIDER_CAPABILITY\"\napi_backend = \"chat_completions\"\ncontext_window = 131072\nsupports_backend_search = false\nmax_retries = 0\n\n[[model.daimon-broker-grok.reasoning_efforts]]\nvalue = \"medium\"\nlabel = \"Medium\"\ndefault = true\n\n[mcp_servers.daimon]\nurl = \"http://127.0.0.1:43124/mcp\"\nbearer_token_env_var = \"DAIMON_MCP_CAPABILITY\"\n\n[shell_environment_policy]\ninherit=\"all\"\nignore_default_excludes=false\nexclude=[]\ninclude_only=[]\nset={\"LD_PRELOAD\"=\"/usr/local/lib/daimon/libfaketime.so.1\",\"FAKETIME_TIMESTAMP_FILE\"=\"/run/daimon-engine-broker/task-clock/slot-0/faketime\",\"FAKETIME_DONT_FAKE_MONOTONIC\"=\"1\",\"LC_ALL\"=\"C\"}\n", + "high": "[cli]\nauto_update = false\nuse_leader = false\nshow_tips = false\n\n[features]\ntelemetry = false\ntitle_refresh = false\nsession_recap = false\nturn_summary = false\nrepo_status_in_system_prompt = false\ncodebase_indexing = false\nbackend_tools = false\nask_user_question = false\nimage_gen = false\nvideo_gen = false\nweb_fetch = false\ncampaigns = false\nmanaged_config = false\n\n[managed_mcps]\nenabled = false\n\n[skills]\ndisabled = [\"build-with-ai\", \"code-review\", \"create-skill\", \"create-workflow\", \"design\", \"docx\", \"execute-plan\", \"game-animation-frames\", \"game-asset-core\", \"game-character-consistency\", \"game-tilesets\", \"game-ui-icons\", \"imagine\", \"implement\", \"learn\", \"long-running-background-tasks\", \"pdf\", \"pptx\", \"pr-babysit\", \"resume-claude\", \"resume-codex\", \"resume-cursor\", \"review\", \"skill-design-principles\", \"statusline\"]\n\n[workflows]\nenabled = false\n\n[models]\ndefault = \"daimon-broker-grok\"\ndefault_reasoning_effort = \"high\"\nsession_summary = \"daimon-session-title-disabled\"\n\n[model.daimon-session-title-disabled]\nmodel = \"disabled\"\nbase_url = \"http://127.0.0.1:43123/v1\"\napi_key = \"session-title-disabled\"\nmax_retries = 0\nhidden = true\n\n[model.daimon-broker-grok]\nmodel = \"grok-4.5\"\nbase_url = \"http://127.0.0.1:43123/v1\"\nenv_key = \"DAIMON_PROVIDER_CAPABILITY\"\napi_backend = \"chat_completions\"\ncontext_window = 131072\nsupports_backend_search = false\nmax_retries = 0\n\n[[model.daimon-broker-grok.reasoning_efforts]]\nvalue = \"high\"\nlabel = \"High\"\ndefault = true\n\n[mcp_servers.daimon]\nurl = \"http://127.0.0.1:43124/mcp\"\nbearer_token_env_var = \"DAIMON_MCP_CAPABILITY\"\n\n[shell_environment_policy]\ninherit=\"all\"\nignore_default_excludes=false\nexclude=[]\ninclude_only=[]\nset={\"LD_PRELOAD\"=\"/usr/local/lib/daimon/libfaketime.so.1\",\"FAKETIME_TIMESTAMP_FILE\"=\"/run/daimon-engine-broker/task-clock/slot-0/faketime\",\"FAKETIME_DONT_FAKE_MONOTONIC\"=\"1\",\"LC_ALL\"=\"C\"}\n" }, "grok-build": { - "low": "[cli]\nauto_update = false\nuse_leader = false\nshow_tips = false\n\n[features]\ntelemetry = false\ntitle_refresh = false\nsession_recap = false\nturn_summary = false\nrepo_status_in_system_prompt = false\ncodebase_indexing = false\nbackend_tools = false\nask_user_question = false\nimage_gen = false\nvideo_gen = false\nweb_fetch = false\ncampaigns = false\nmanaged_config = false\n\n[managed_mcps]\nenabled = false\n\n[skills]\ndisabled = [\"build-with-ai\", \"code-review\", \"create-skill\", \"create-workflow\", \"design\", \"docx\", \"execute-plan\", \"game-animation-frames\", \"game-asset-core\", \"game-character-consistency\", \"game-tilesets\", \"game-ui-icons\", \"imagine\", \"implement\", \"learn\", \"long-running-background-tasks\", \"pdf\", \"pptx\", \"pr-babysit\", \"resume-claude\", \"resume-codex\", \"resume-cursor\", \"review\", \"skill-design-principles\", \"statusline\"]\n\n[workflows]\nenabled = false\n\n[models]\ndefault = \"daimon-broker-grok\"\ndefault_reasoning_effort = \"low\"\nsession_summary = \"daimon-session-title-disabled\"\n\n[model.daimon-session-title-disabled]\nmodel = \"disabled\"\nbase_url = \"http://127.0.0.1:43123/v1\"\napi_key = \"session-title-disabled\"\nmax_retries = 0\nhidden = true\n\n[model.daimon-broker-grok]\nmodel = \"grok-build\"\nbase_url = \"http://127.0.0.1:43123/v1\"\nenv_key = \"DAIMON_PROVIDER_CAPABILITY\"\napi_backend = \"chat_completions\"\ncontext_window = 131072\nsupports_backend_search = false\nmax_retries = 0\n\n[[model.daimon-broker-grok.reasoning_efforts]]\nvalue = \"low\"\nlabel = \"Low\"\ndefault = true\n\n[mcp_servers.daimon]\nurl = \"http://127.0.0.1:43124/mcp\"\nbearer_token_env_var = \"DAIMON_MCP_CAPABILITY\"\n\n[shell_environment_policy]\ninherit=\"all\"\nignore_default_excludes=false\nexclude=[]\ninclude_only=[]\nset={\"LD_PRELOAD\"=\"/usr/local/lib/daimon/libfaketime.so.1\",\"FAKETIME_TIMESTAMP_FILE\"=\"/run/daimon-engine-broker/task-clock/faketime\",\"FAKETIME_DONT_FAKE_MONOTONIC\"=\"1\",\"LC_ALL\"=\"C\"}\n", - "medium": "[cli]\nauto_update = false\nuse_leader = false\nshow_tips = false\n\n[features]\ntelemetry = false\ntitle_refresh = false\nsession_recap = false\nturn_summary = false\nrepo_status_in_system_prompt = false\ncodebase_indexing = false\nbackend_tools = false\nask_user_question = false\nimage_gen = false\nvideo_gen = false\nweb_fetch = false\ncampaigns = false\nmanaged_config = false\n\n[managed_mcps]\nenabled = false\n\n[skills]\ndisabled = [\"build-with-ai\", \"code-review\", \"create-skill\", \"create-workflow\", \"design\", \"docx\", \"execute-plan\", \"game-animation-frames\", \"game-asset-core\", \"game-character-consistency\", \"game-tilesets\", \"game-ui-icons\", \"imagine\", \"implement\", \"learn\", \"long-running-background-tasks\", \"pdf\", \"pptx\", \"pr-babysit\", \"resume-claude\", \"resume-codex\", \"resume-cursor\", \"review\", \"skill-design-principles\", \"statusline\"]\n\n[workflows]\nenabled = false\n\n[models]\ndefault = \"daimon-broker-grok\"\ndefault_reasoning_effort = \"medium\"\nsession_summary = \"daimon-session-title-disabled\"\n\n[model.daimon-session-title-disabled]\nmodel = \"disabled\"\nbase_url = \"http://127.0.0.1:43123/v1\"\napi_key = \"session-title-disabled\"\nmax_retries = 0\nhidden = true\n\n[model.daimon-broker-grok]\nmodel = \"grok-build\"\nbase_url = \"http://127.0.0.1:43123/v1\"\nenv_key = \"DAIMON_PROVIDER_CAPABILITY\"\napi_backend = \"chat_completions\"\ncontext_window = 131072\nsupports_backend_search = false\nmax_retries = 0\n\n[[model.daimon-broker-grok.reasoning_efforts]]\nvalue = \"medium\"\nlabel = \"Medium\"\ndefault = true\n\n[mcp_servers.daimon]\nurl = \"http://127.0.0.1:43124/mcp\"\nbearer_token_env_var = \"DAIMON_MCP_CAPABILITY\"\n\n[shell_environment_policy]\ninherit=\"all\"\nignore_default_excludes=false\nexclude=[]\ninclude_only=[]\nset={\"LD_PRELOAD\"=\"/usr/local/lib/daimon/libfaketime.so.1\",\"FAKETIME_TIMESTAMP_FILE\"=\"/run/daimon-engine-broker/task-clock/faketime\",\"FAKETIME_DONT_FAKE_MONOTONIC\"=\"1\",\"LC_ALL\"=\"C\"}\n", - "high": "[cli]\nauto_update = false\nuse_leader = false\nshow_tips = false\n\n[features]\ntelemetry = false\ntitle_refresh = false\nsession_recap = false\nturn_summary = false\nrepo_status_in_system_prompt = false\ncodebase_indexing = false\nbackend_tools = false\nask_user_question = false\nimage_gen = false\nvideo_gen = false\nweb_fetch = false\ncampaigns = false\nmanaged_config = false\n\n[managed_mcps]\nenabled = false\n\n[skills]\ndisabled = [\"build-with-ai\", \"code-review\", \"create-skill\", \"create-workflow\", \"design\", \"docx\", \"execute-plan\", \"game-animation-frames\", \"game-asset-core\", \"game-character-consistency\", \"game-tilesets\", \"game-ui-icons\", \"imagine\", \"implement\", \"learn\", \"long-running-background-tasks\", \"pdf\", \"pptx\", \"pr-babysit\", \"resume-claude\", \"resume-codex\", \"resume-cursor\", \"review\", \"skill-design-principles\", \"statusline\"]\n\n[workflows]\nenabled = false\n\n[models]\ndefault = \"daimon-broker-grok\"\ndefault_reasoning_effort = \"high\"\nsession_summary = \"daimon-session-title-disabled\"\n\n[model.daimon-session-title-disabled]\nmodel = \"disabled\"\nbase_url = \"http://127.0.0.1:43123/v1\"\napi_key = \"session-title-disabled\"\nmax_retries = 0\nhidden = true\n\n[model.daimon-broker-grok]\nmodel = \"grok-build\"\nbase_url = \"http://127.0.0.1:43123/v1\"\nenv_key = \"DAIMON_PROVIDER_CAPABILITY\"\napi_backend = \"chat_completions\"\ncontext_window = 131072\nsupports_backend_search = false\nmax_retries = 0\n\n[[model.daimon-broker-grok.reasoning_efforts]]\nvalue = \"high\"\nlabel = \"High\"\ndefault = true\n\n[mcp_servers.daimon]\nurl = \"http://127.0.0.1:43124/mcp\"\nbearer_token_env_var = \"DAIMON_MCP_CAPABILITY\"\n\n[shell_environment_policy]\ninherit=\"all\"\nignore_default_excludes=false\nexclude=[]\ninclude_only=[]\nset={\"LD_PRELOAD\"=\"/usr/local/lib/daimon/libfaketime.so.1\",\"FAKETIME_TIMESTAMP_FILE\"=\"/run/daimon-engine-broker/task-clock/faketime\",\"FAKETIME_DONT_FAKE_MONOTONIC\"=\"1\",\"LC_ALL\"=\"C\"}\n" + "low": "[cli]\nauto_update = false\nuse_leader = false\nshow_tips = false\n\n[features]\ntelemetry = false\ntitle_refresh = false\nsession_recap = false\nturn_summary = false\nrepo_status_in_system_prompt = false\ncodebase_indexing = false\nbackend_tools = false\nask_user_question = false\nimage_gen = false\nvideo_gen = false\nweb_fetch = false\ncampaigns = false\nmanaged_config = false\n\n[managed_mcps]\nenabled = false\n\n[skills]\ndisabled = [\"build-with-ai\", \"code-review\", \"create-skill\", \"create-workflow\", \"design\", \"docx\", \"execute-plan\", \"game-animation-frames\", \"game-asset-core\", \"game-character-consistency\", \"game-tilesets\", \"game-ui-icons\", \"imagine\", \"implement\", \"learn\", \"long-running-background-tasks\", \"pdf\", \"pptx\", \"pr-babysit\", \"resume-claude\", \"resume-codex\", \"resume-cursor\", \"review\", \"skill-design-principles\", \"statusline\"]\n\n[workflows]\nenabled = false\n\n[models]\ndefault = \"daimon-broker-grok\"\ndefault_reasoning_effort = \"low\"\nsession_summary = \"daimon-session-title-disabled\"\n\n[model.daimon-session-title-disabled]\nmodel = \"disabled\"\nbase_url = \"http://127.0.0.1:43123/v1\"\napi_key = \"session-title-disabled\"\nmax_retries = 0\nhidden = true\n\n[model.daimon-broker-grok]\nmodel = \"grok-build\"\nbase_url = \"http://127.0.0.1:43123/v1\"\nenv_key = \"DAIMON_PROVIDER_CAPABILITY\"\napi_backend = \"chat_completions\"\ncontext_window = 131072\nsupports_backend_search = false\nmax_retries = 0\n\n[[model.daimon-broker-grok.reasoning_efforts]]\nvalue = \"low\"\nlabel = \"Low\"\ndefault = true\n\n[mcp_servers.daimon]\nurl = \"http://127.0.0.1:43124/mcp\"\nbearer_token_env_var = \"DAIMON_MCP_CAPABILITY\"\n\n[shell_environment_policy]\ninherit=\"all\"\nignore_default_excludes=false\nexclude=[]\ninclude_only=[]\nset={\"LD_PRELOAD\"=\"/usr/local/lib/daimon/libfaketime.so.1\",\"FAKETIME_TIMESTAMP_FILE\"=\"/run/daimon-engine-broker/task-clock/slot-0/faketime\",\"FAKETIME_DONT_FAKE_MONOTONIC\"=\"1\",\"LC_ALL\"=\"C\"}\n", + "medium": "[cli]\nauto_update = false\nuse_leader = false\nshow_tips = false\n\n[features]\ntelemetry = false\ntitle_refresh = false\nsession_recap = false\nturn_summary = false\nrepo_status_in_system_prompt = false\ncodebase_indexing = false\nbackend_tools = false\nask_user_question = false\nimage_gen = false\nvideo_gen = false\nweb_fetch = false\ncampaigns = false\nmanaged_config = false\n\n[managed_mcps]\nenabled = false\n\n[skills]\ndisabled = [\"build-with-ai\", \"code-review\", \"create-skill\", \"create-workflow\", \"design\", \"docx\", \"execute-plan\", \"game-animation-frames\", \"game-asset-core\", \"game-character-consistency\", \"game-tilesets\", \"game-ui-icons\", \"imagine\", \"implement\", \"learn\", \"long-running-background-tasks\", \"pdf\", \"pptx\", \"pr-babysit\", \"resume-claude\", \"resume-codex\", \"resume-cursor\", \"review\", \"skill-design-principles\", \"statusline\"]\n\n[workflows]\nenabled = false\n\n[models]\ndefault = \"daimon-broker-grok\"\ndefault_reasoning_effort = \"medium\"\nsession_summary = \"daimon-session-title-disabled\"\n\n[model.daimon-session-title-disabled]\nmodel = \"disabled\"\nbase_url = \"http://127.0.0.1:43123/v1\"\napi_key = \"session-title-disabled\"\nmax_retries = 0\nhidden = true\n\n[model.daimon-broker-grok]\nmodel = \"grok-build\"\nbase_url = \"http://127.0.0.1:43123/v1\"\nenv_key = \"DAIMON_PROVIDER_CAPABILITY\"\napi_backend = \"chat_completions\"\ncontext_window = 131072\nsupports_backend_search = false\nmax_retries = 0\n\n[[model.daimon-broker-grok.reasoning_efforts]]\nvalue = \"medium\"\nlabel = \"Medium\"\ndefault = true\n\n[mcp_servers.daimon]\nurl = \"http://127.0.0.1:43124/mcp\"\nbearer_token_env_var = \"DAIMON_MCP_CAPABILITY\"\n\n[shell_environment_policy]\ninherit=\"all\"\nignore_default_excludes=false\nexclude=[]\ninclude_only=[]\nset={\"LD_PRELOAD\"=\"/usr/local/lib/daimon/libfaketime.so.1\",\"FAKETIME_TIMESTAMP_FILE\"=\"/run/daimon-engine-broker/task-clock/slot-0/faketime\",\"FAKETIME_DONT_FAKE_MONOTONIC\"=\"1\",\"LC_ALL\"=\"C\"}\n", + "high": "[cli]\nauto_update = false\nuse_leader = false\nshow_tips = false\n\n[features]\ntelemetry = false\ntitle_refresh = false\nsession_recap = false\nturn_summary = false\nrepo_status_in_system_prompt = false\ncodebase_indexing = false\nbackend_tools = false\nask_user_question = false\nimage_gen = false\nvideo_gen = false\nweb_fetch = false\ncampaigns = false\nmanaged_config = false\n\n[managed_mcps]\nenabled = false\n\n[skills]\ndisabled = [\"build-with-ai\", \"code-review\", \"create-skill\", \"create-workflow\", \"design\", \"docx\", \"execute-plan\", \"game-animation-frames\", \"game-asset-core\", \"game-character-consistency\", \"game-tilesets\", \"game-ui-icons\", \"imagine\", \"implement\", \"learn\", \"long-running-background-tasks\", \"pdf\", \"pptx\", \"pr-babysit\", \"resume-claude\", \"resume-codex\", \"resume-cursor\", \"review\", \"skill-design-principles\", \"statusline\"]\n\n[workflows]\nenabled = false\n\n[models]\ndefault = \"daimon-broker-grok\"\ndefault_reasoning_effort = \"high\"\nsession_summary = \"daimon-session-title-disabled\"\n\n[model.daimon-session-title-disabled]\nmodel = \"disabled\"\nbase_url = \"http://127.0.0.1:43123/v1\"\napi_key = \"session-title-disabled\"\nmax_retries = 0\nhidden = true\n\n[model.daimon-broker-grok]\nmodel = \"grok-build\"\nbase_url = \"http://127.0.0.1:43123/v1\"\nenv_key = \"DAIMON_PROVIDER_CAPABILITY\"\napi_backend = \"chat_completions\"\ncontext_window = 131072\nsupports_backend_search = false\nmax_retries = 0\n\n[[model.daimon-broker-grok.reasoning_efforts]]\nvalue = \"high\"\nlabel = \"High\"\ndefault = true\n\n[mcp_servers.daimon]\nurl = \"http://127.0.0.1:43124/mcp\"\nbearer_token_env_var = \"DAIMON_MCP_CAPABILITY\"\n\n[shell_environment_policy]\ninherit=\"all\"\nignore_default_excludes=false\nexclude=[]\ninclude_only=[]\nset={\"LD_PRELOAD\"=\"/usr/local/lib/daimon/libfaketime.so.1\",\"FAKETIME_TIMESTAMP_FILE\"=\"/run/daimon-engine-broker/task-clock/slot-0/faketime\",\"FAKETIME_DONT_FAKE_MONOTONIC\"=\"1\",\"LC_ALL\"=\"C\"}\n" } } as const; diff --git a/src/runtime/daimon/grokWorkerContract.test.ts b/src/runtime/daimon/grokWorkerContract.test.ts index 52bc75e3..1093d636 100644 --- a/src/runtime/daimon/grokWorkerContract.test.ts +++ b/src/runtime/daimon/grokWorkerContract.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest"; import { DAIMON_GROK_BROKER_MODELS, DAIMON_GROK_BROKER_REASONING_EFFORTS, DAIMON_GROK_ENGINE_BROKER } from "./contractManifest.js"; import { DAIMON_GROK_CLOCKED_WORKER_CONFIG_BYTES, DAIMON_GROK_WORKER_CONFIG_BYTES, DAIMON_GROK_WORKER_PROFILE_SAMPLES } from "./grokWorkerConfigBytes.js"; -import { renderDaimonGrokWorkerSandboxProfile, resolveDaimonGrokWorkerConfig } from "./grokWorkerContract.js"; +import { renderDaimonGrokWorkerSandboxProfile, resolveDaimonGrokWorkerConfig, resolveDaimonGrokClockedWorkerConfig } from "./grokWorkerContract.js"; const sha256 = (value: string): string => createHash("sha256").update(value).digest("hex"); @@ -17,13 +17,6 @@ describe("vendored Daimon Grok worker contract", () => { expect(sha256(resolved.bytes)).toBe(DAIMON_GROK_ENGINE_BROKER.worker.configSha256[model][effort]); expect(resolved.bytes).toContain(`model = "${model}"`); expect(resolved.bytes).toContain(`value = "${effort}"`); - expect(resolved.bytes).not.toMatch(/LD_PRELOAD|FAKETIME/u); - const clocked = resolveDaimonGrokWorkerConfig(model, effort, "clocked"); - expect(clocked.bytes).toBe(DAIMON_GROK_CLOCKED_WORKER_CONFIG_BYTES[model][effort]); - expect(clocked.sha256).toBe(DAIMON_GROK_ENGINE_BROKER.worker.clockedConfigSha256[model][effort]); - expect(sha256(clocked.bytes)).toBe(clocked.sha256); - expect(clocked.bytes).toContain(DAIMON_GROK_ENGINE_BROKER.worker.taskClock.preloadPath); - expect(clocked.bytes).toContain(DAIMON_GROK_ENGINE_BROKER.worker.taskClock.timestampFilePath); } } }); @@ -33,11 +26,17 @@ describe("vendored Daimon Grok worker contract", () => { ...DAIMON_GROK_WORKER_CONFIG_BYTES, "grok-4.6": { ...DAIMON_GROK_WORKER_CONFIG_BYTES["grok-4.6"], low: DAIMON_GROK_WORKER_CONFIG_BYTES["grok-4.6"].low.replace("grok-4.6", "grok-4.5") } }; - expect(() => resolveDaimonGrokWorkerConfig("grok-4.6", "low", "unclocked", tampered)).toThrow(/does not match the contract manifest pin/u); + expect(() => resolveDaimonGrokWorkerConfig("grok-4.6", "low", tampered)).toThrow(/does not match the contract manifest pin/u); const swapped = { ...DAIMON_GROK_WORKER_CONFIG_BYTES, "grok-4.6": { ...DAIMON_GROK_WORKER_CONFIG_BYTES["grok-4.6"], low: DAIMON_GROK_WORKER_CONFIG_BYTES["grok-4.6"].medium } }; - expect(() => resolveDaimonGrokWorkerConfig("grok-4.6", "low", "unclocked", swapped)).toThrow(/manifest pin/u); - expect(() => resolveDaimonGrokWorkerConfig("grok-4.6", "low", "clocked", DAIMON_GROK_WORKER_CONFIG_BYTES)).toThrow(/manifest pin/u); - expect(() => resolveDaimonGrokWorkerConfig("grok-4.6", "low", "unclocked", DAIMON_GROK_CLOCKED_WORKER_CONFIG_BYTES)).toThrow(/manifest pin/u); + expect(() => resolveDaimonGrokWorkerConfig("grok-4.6", "low", swapped)).toThrow(/manifest pin/u); + }); + + it("refuses a changed clocked template and invalid slot before deriving slot-specific bytes", () => { + expect(() => resolveDaimonGrokClockedWorkerConfig("grok-4.6", "low", 2, { + ...DAIMON_GROK_CLOCKED_WORKER_CONFIG_BYTES, + "grok-4.6": { ...DAIMON_GROK_CLOCKED_WORKER_CONFIG_BYTES["grok-4.6"], low: DAIMON_GROK_CLOCKED_WORKER_CONFIG_BYTES["grok-4.6"].low + " " } + })).toThrow(/manifest pin/); + for (const slot of [-1, 0.5, NaN]) expect(() => resolveDaimonGrokClockedWorkerConfig("grok-4.6", "low", slot)).toThrow(/slot/); }); it("renders sandbox profile bytes identical to Daimon's renderer", () => { diff --git a/src/runtime/daimon/grokWorkerContract.ts b/src/runtime/daimon/grokWorkerContract.ts index 03a9fe4c..6182b842 100644 --- a/src/runtime/daimon/grokWorkerContract.ts +++ b/src/runtime/daimon/grokWorkerContract.ts @@ -8,9 +8,7 @@ import { type DaimonGrokBrokerModel, type DaimonGrokBrokerReasoningEffort } from "./contractManifest.js"; -import { DAIMON_GROK_CLOCKED_WORKER_CONFIG_BYTES, DAIMON_GROK_WORKER_CONFIG_BYTES } from "./grokWorkerConfigBytes.js"; - -export type DaimonGrokWorkerConfigVariant = "unclocked" | "clocked"; +import { DAIMON_GROK_WORKER_CONFIG_BYTES, DAIMON_GROK_CLOCKED_WORKER_CONFIG_BYTES } from "./grokWorkerConfigBytes.js"; export const DAIMON_GROK_WORKER_SANDBOX_PROFILE = "daimon-strict" as const; export const DAIMON_GROK_WORKER_HOME_DIRECTORY = ".grok" as const; @@ -28,10 +26,9 @@ const UNSAFE_DENY_PATH_CHARACTER = new RegExp("[\"\\\\\\u0000-\\u001f\\u007f*?[\ export const resolveDaimonGrokWorkerConfig = ( model: DaimonGrokBrokerModel, reasoningEffort: DaimonGrokBrokerReasoningEffort, - variant: DaimonGrokWorkerConfigVariant = "unclocked", - vendored: Readonly>>> = variant === "clocked" ? DAIMON_GROK_CLOCKED_WORKER_CONFIG_BYTES : DAIMON_GROK_WORKER_CONFIG_BYTES + vendored: Readonly>>> = DAIMON_GROK_WORKER_CONFIG_BYTES ): { bytes: string; sha256: string } => { - const pins = (variant === "clocked" ? DAIMON_GROK_ENGINE_BROKER.worker.clockedConfigSha256 : DAIMON_GROK_ENGINE_BROKER.worker.configSha256) as Readonly> | undefined>>; + const pins = DAIMON_GROK_ENGINE_BROKER.worker.configSha256 as Readonly> | undefined>>; const pin = pins[model]?.[reasoningEffort]; const bytes = vendored[model]?.[reasoningEffort]; if (pin === undefined || bytes === undefined || sha256(bytes) !== pin) { @@ -43,6 +40,20 @@ export const resolveDaimonGrokWorkerConfig = ( return { bytes, sha256: pin }; }; +/** Validate Daimon's slot-zero template before substituting only the canonical slot path. */ +export const resolveDaimonGrokClockedWorkerConfig = ( + model: DaimonGrokBrokerModel, reasoningEffort: DaimonGrokBrokerReasoningEffort, slot: number, + vendored: Readonly>>> = DAIMON_GROK_CLOCKED_WORKER_CONFIG_BYTES +): { bytes: string; sha256: string } => { + if (!Number.isSafeInteger(slot) || slot < 0) throw new SpawnfileError("compile_error", "Invalid Grok clock slot"); + const template = vendored[model]?.[reasoningEffort]; + const pin = DAIMON_GROK_ENGINE_BROKER.worker.clockedConfigSha256[model]?.[reasoningEffort]; + if (!template || sha256(template) !== pin) throw new SpawnfileError("compile_error", "Clocked worker config does not match the contract manifest pin"); + const clock = DAIMON_GROK_ENGINE_BROKER.worker.taskClock; + const bytes = template.replaceAll(clock.timestampFilePath, clock.slotFilePattern.replace("{slot}", String(slot))); + return { bytes, sha256: sha256(bytes) }; +}; + /** * Byte mirror of Daimon's `renderGrokWorkerSandboxProfile` * (`daimon/src/runtime/grokWorkerSandboxProfile.ts`), pinned to Daimon's own diff --git a/src/runtime/daimon/recording.test-helper.test.ts b/src/runtime/daimon/recording.test-helper.test.ts new file mode 100644 index 00000000..d4c75891 --- /dev/null +++ b/src/runtime/daimon/recording.test-helper.test.ts @@ -0,0 +1,17 @@ +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +import { daimonSupportsWakeMoments, readVerifiedDaimonContractManifest } from "./contractManifest.js"; +import { capableDaimonManifest } from "./recording.test-helper.js"; +import { DAIMON_WAKE_MOMENTS } from "./wakeMoments.js"; + +describe("recording manifest fixture", () => { + it("uses the verified packaged capability without duplicating consumed fields", async () => { + const manifest = capableDaimonManifest(); + const packaged = await readVerifiedDaimonContractManifest(fileURLToPath(new URL(".", import.meta.url))); + expect(manifest).toEqual(packaged.manifest); + expect(daimonSupportsWakeMoments(manifest)).toBe(true); + expect(manifest.consumedConfigFields.filter((field) => field === DAIMON_WAKE_MOMENTS.configField)) + .toEqual([DAIMON_WAKE_MOMENTS.configField]); + }); +}); diff --git a/src/runtime/daimon/recording.test-helper.ts b/src/runtime/daimon/recording.test-helper.ts index 28e41f69..e3e26148 100644 --- a/src/runtime/daimon/recording.test-helper.ts +++ b/src/runtime/daimon/recording.test-helper.ts @@ -1,28 +1,8 @@ -import { readFileSync, writeFileSync } from "node:fs"; -import path from "node:path"; -import { vi } from "vitest"; -import { DAIMON_CONTRACT_MANIFEST_SHA256, parseDaimonContractManifest, type DaimonContractManifest } from "./contractManifest.js"; -import { DAIMON_WAKE_MOMENTS } from "./wakeMoments.js"; +import { readFileSync } from "node:fs"; +import { parseDaimonContractManifest, type DaimonContractManifest } from "./contractManifest.js"; /** Capability fixture only: never writes or re-pins the packaged manifest. */ export const capableDaimonManifest = (): DaimonContractManifest => { const manifest = JSON.parse(readFileSync(new URL("./contract-manifest.json", import.meta.url), "utf8")); - return parseDaimonContractManifest({ - ...manifest, wakeMoments: DAIMON_WAKE_MOMENTS, - consumedConfigFields: [...new Set([...manifest.consumedConfigFields, DAIMON_WAKE_MOMENTS.configField])] - }); -}; - -/** Explicit test-only image authority; callers restore stubbed env and remove their fixture root. */ -export const useMatchingDaimonTestImage = (directory: string): void => { - const identity = path.join(directory, "daimon-test-identity.json"), digest = `sha256:${"a".repeat(64)}`; - writeFileSync(identity, JSON.stringify({ - capability_receipt_sha256: digest, - development: { mode: "local-development", non_production: true, unpublished: true, unsigned: true }, - image_architecture: "amd64", image_config_digest: digest, image_manifest_digest: digest, - image_reference: `127.0.0.1:54321/noopolis/spawnfile-runtime-daimon@${digest}`, - manifest_sha256: DAIMON_CONTRACT_MANIFEST_SHA256, registry_authority: "127.0.0.1:54321", - version: "spawnfile.local-daimon-runtime-identity.v3" - })); - vi.stubEnv("SPAWNFILE_DAIMON_LOCAL_RUNTIME_IDENTITY", identity); + return parseDaimonContractManifest(manifest); }; diff --git a/src/runtime/daimon/scheduleAuthority.test.ts b/src/runtime/daimon/scheduleAuthority.test.ts index 505ce3b2..d9ca297a 100644 --- a/src/runtime/daimon/scheduleAuthority.test.ts +++ b/src/runtime/daimon/scheduleAuthority.test.ts @@ -33,13 +33,18 @@ const scheduledTarget = async (kind: "every" | "disabled" = "every") => { afterEach(async () => { delete process.env.SPAWNFILE_DAIMON_LOCAL_RUNTIME_IDENTITY; await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); describe("Daimon schedule image authority", () => { - it("requires a re-pin before the checked-in published image grants schedule authority", async () => { + it("accepts the checked-in published pin, whose receipts attest the compiler's contract manifest", async () => { await expect(daimonAdapter.compileAgent(createPiTestNode({ runtime: { name: "daimon", options: {} }, schedule: { every: "1m", kind: "every" } }))).resolves.toMatchObject({ capabilities: expect.arrayContaining([ - expect.objectContaining({ key: "agent.schedule", outcome: "degraded" }) + expect.objectContaining({ key: "agent.schedule", outcome: "supported" }) ]) }); - await expect(scheduledTarget()).rejects.toThrow(/does not attest organization runtime v2/u); + const targets = await scheduledTarget(); + expect(JSON.parse(targets[0]!.files.find((file) => file.path === DAIMON_CONFIG_FILE)!.content).version).toBe("noopolis.daimon.organization-runtime.v2"); + const node = createPiTestNode({ runtime: { name: "daimon", options: {} } }); + const compiled = await daimonAdapter.compileAgent(node); + const unscheduled = await daimonAdapter.createContainerTargets!([{ emittedFiles: compiled.files, id: "agent:v1", kind: "agent", slug: "v1", value: node }]); + expect(JSON.parse(unscheduled[0]!.files.find((file) => file.path === DAIMON_CONFIG_FILE)!.content).version).toBe("noopolis.daimon.organization-runtime.v1"); }); it("supports active and disabled v2 states only with the canonical local receipt authority", async () => { diff --git a/src/runtime/install.test.ts b/src/runtime/install.test.ts index bb2e1ccf..4a9dca93 100644 --- a/src/runtime/install.test.ts +++ b/src/runtime/install.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from "vitest"; -import { DAIMON_CONTRACT_MANIFEST_SHA256 } from "./daimon/contractManifest.js"; import { assertInstallSelectionsCoverCompileableRuntimes, @@ -18,23 +17,21 @@ describe("runtime install selection", () => { ]); }); - it("preserves the published install pin and its old manifest; requires a re-pin", async () => { + it("resolves Daimon install selection from the pinned runtime image", async () => { await expect(resolveRuntimeInstallSelection("daimon")).resolves.toEqual({ capabilityReceipt: undefined, - capabilityReceipts: { amd64: "sha256:4714bbf377acdb098fcf90c0c91e55494f0996fca77e28cd599b6a72c7cac2dc", arm64: "sha256:f48018b729b984fde30af433607087c2f72019aad59ccf26d2641f18757c2dc6" }, - contractManifestSha256: "sha256:f7bdd283c420cb6685d32fb556bfb1dcf48f756b3a5ca5ae61e96950152a5cc0", - digest: "sha256:85875f67af630e8b4e667a72f20f0b491940cfb3feb7fe132a13a84678147747", + capabilityReceipts: { amd64: "sha256:9db4f4d5b62c505a445429da9ae8cf8bce21590bd1b67f449d17e427934a5421", arm64: "sha256:8536fc5543378a8617a99bbdbddd0b6e909a6c0d4ed5f9bb5805d501a17ca90c" }, + contractManifestSha256: "sha256:e39551277c6727c8807b1247e759f57addcee3f17f1405ebc927179647d64de6", + digest: "sha256:d4389f1cb99a8d2bf13134737ae058c0e48019d465bd35fbb250d7cde138b836", ecosystem: "node", image: "noopolis/spawnfile-runtime-daimon", installHint: "Copy a pinned Daimon runtime image.", kind: "container_image", runtimeName: "daimon", - runtimeRef: "834955934e102349335362c37524261625e38dbb", + runtimeRef: "0b3bc7cb2d5b92d4eaf222066c3817191190be7e", selectionSource: "runtime_registry_install", - tag: "0.2.0-8349559" + tag: "0.2.0-0b3bc7c" }); - const selection = await resolveRuntimeInstallSelection("daimon"); - expect(selection.kind === "container_image" && selection.contractManifestSha256).not.toBe(DAIMON_CONTRACT_MANIFEST_SHA256); }); it("resolves OpenClaw install selection from the pinned runtime image", async () => { 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)) {