diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 565c32ee..aa71e83e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -47,6 +47,9 @@ jobs: - name: Verify native helper syscalls run: node --test scripts/native-helper-artifacts.test.mjs scripts/native-helper-integration.test.mjs + - name: Verify Daimon image layering + run: node --test scripts/build-local-daimon-runtime.test.mjs + # Preseed reads the candidate volume through its Docker Mountpoint and # relies on host rename/fsync/hardlink semantics a container cannot give # it. On a rootful Linux daemon /var/lib/docker/volumes/*/_data is diff --git a/runtime-images/daimon/Dockerfile b/runtime-images/daimon/Dockerfile index d7438d01..f2768443 100644 --- a/runtime-images/daimon/Dockerfile +++ b/runtime-images/daimon/Dockerfile @@ -1,5 +1,6 @@ # syntax=docker/dockerfile:1 +ARG DAIMON_DEPENDENCY_MODE=registry ARG NODE_BASE_IMAGE=node:24-bookworm-slim@sha256:a9f5f7c91a432850b2a8a7797adf5eadb6c733ceed61167806cee7ea7fbc29df FROM daimon_package AS daimon_package @@ -18,9 +19,92 @@ RUN test "$(sha256sum /tmp/daimon.tgz | awk '{print "sha256:" $1}')" = "${DAIMON && test -f /probe/node_modules/@noopolis/daimon/dist/runtime/contract-manifest.json \ && cp /tmp/source-inputs.json /probe/source-inputs.json -FROM ${NODE_BASE_IMAGE} AS build +FROM ${NODE_BASE_IMAGE} AS base_offline-bundle + +FROM ${NODE_BASE_IMAGE} AS base_registry +RUN apt-get update \ + && apt-get install --yes --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* + +FROM base_${DAIMON_DEPENDENCY_MODE} AS base + +FROM base AS grok_source_registry +ARG GROK_CLI_URL +RUN curl -fsSL "${GROK_CLI_URL}" -o /tmp/grok + +FROM base AS grok_source_offline-bundle +COPY --from=daimon_package /grok /tmp/grok + +FROM grok_source_${DAIMON_DEPENDENCY_MODE} AS grok_cli +ARG GROK_CLI_SHA256 +ARG RUNTIME_ROOT=/opt/spawnfile/runtime-installs/daimon +RUN echo "${GROK_CLI_SHA256} /tmp/grok" | sha256sum -c - \ + && mkdir -p ${RUNTIME_ROOT}/bin \ + && install -m 0755 /tmp/grok ${RUNTIME_ROOT}/bin/grok \ + && test "$(sha256sum ${RUNTIME_ROOT}/bin/grok | awk '{print "sha256:" $1}')" = "sha256:${GROK_CLI_SHA256#sha256:}" \ + && rm -f /tmp/grok + +FROM grok_cli AS agy_source_registry +ARG AGY_CLI_URL +RUN curl -fsSL "${AGY_CLI_URL}" -o /tmp/agy.tar.gz + +FROM grok_cli AS agy_source_offline-bundle +COPY --from=daimon_package /agy.tar.gz /tmp/agy.tar.gz +FROM agy_source_${DAIMON_DEPENDENCY_MODE} AS agy_cli +ARG AGY_CLI_SHA512 +ARG AGY_CLI_SHA256 +ARG RUNTIME_ROOT=/opt/spawnfile/runtime-installs/daimon +RUN echo "${AGY_CLI_SHA512} /tmp/agy.tar.gz" | sha512sum -c - \ + && rm -rf /tmp/agy-extract \ + && mkdir -p /tmp/agy-extract \ + && tar -xzf /tmp/agy.tar.gz -C /tmp/agy-extract \ + && agy_path="$(find /tmp/agy-extract -type f -name antigravity -print -quit)" \ + && test -n "${agy_path}" \ + && install -m 0755 "${agy_path}" ${RUNTIME_ROOT}/bin/agy \ + && test "$(sha256sum ${RUNTIME_ROOT}/bin/agy | awk '{print "sha256:" $1}')" = "sha256:${AGY_CLI_SHA256#sha256:}" \ + && rm -rf /tmp/agy.tar.gz /tmp/agy-extract + +FROM agy_cli AS codex_registry ARG CODEX_CLI_VERSION=0.142.3 +ARG RUNTIME_ROOT=/opt/spawnfile/runtime-installs/daimon +RUN --mount=type=cache,target=/root/.npm,sharing=locked \ + cd ${RUNTIME_ROOT} \ + && npm install --omit=dev --no-fund --no-audit @openai/codex@${CODEX_CLI_VERSION} + +FROM agy_cli AS codex_offline-bundle +ARG DAIMON_DEPENDENCY_ARCHIVE_SHA256=none +ARG RUNTIME_ROOT=/opt/spawnfile/runtime-installs/daimon +COPY --from=daimon_package /dependencies.tar /tmp/dependencies.tar +RUN test "$(sha256sum /tmp/dependencies.tar | awk '{print "sha256:" $1}')" = "${DAIMON_DEPENDENCY_ARCHIVE_SHA256}" \ + && mkdir -p ${RUNTIME_ROOT}/node_modules \ + && tar -xf /tmp/dependencies.tar -C ${RUNTIME_ROOT}/node_modules \ + && test -x ${RUNTIME_ROOT}/node_modules/@openai/codex/bin/codex.js \ + && rm -f /tmp/dependencies.tar + +FROM codex_registry AS daimon_registry +ARG DAIMON_PACKAGE_SHA256 +ARG RUNTIME_ROOT=/opt/spawnfile/runtime-installs/daimon +COPY --from=daimon_package /daimon.tgz /tmp/daimon.tgz +COPY --from=daimon_package /source-inputs.json /tmp/source-inputs.json +RUN --mount=type=cache,target=/root/.npm,sharing=locked \ + test "$(sha256sum /tmp/daimon.tgz | awk '{print "sha256:" $1}')" = "${DAIMON_PACKAGE_SHA256}" \ + && cd ${RUNTIME_ROOT} \ + && npm install --omit=dev --no-fund --no-audit /tmp/daimon.tgz \ + && rm -f /tmp/daimon.tgz + +FROM codex_offline-bundle AS daimon_offline-bundle +ARG DAIMON_PACKAGE_SHA256 +ARG RUNTIME_ROOT=/opt/spawnfile/runtime-installs/daimon +COPY --from=daimon_package /daimon.tgz /tmp/daimon.tgz +COPY --from=daimon_package /source-inputs.json /tmp/source-inputs.json +RUN test "$(sha256sum /tmp/daimon.tgz | awk '{print "sha256:" $1}')" = "${DAIMON_PACKAGE_SHA256}" \ + && mkdir -p ${RUNTIME_ROOT}/node_modules/@noopolis/daimon \ + && tar -xzf /tmp/daimon.tgz -C ${RUNTIME_ROOT}/node_modules/@noopolis/daimon --strip-components=1 \ + && rm -f /tmp/daimon.tgz + +FROM daimon_${DAIMON_DEPENDENCY_MODE} AS build + ARG GROK_CLI_VERSION ARG GROK_CLI_URL ARG GROK_CLI_SHA256 @@ -38,12 +122,6 @@ ARG CODEX_CLI_SHA256 ARG TARGETARCH ARG RUNTIME_ROOT=/opt/spawnfile/runtime-installs/daimon -COPY --from=daimon_package /daimon.tgz /tmp/daimon.tgz -COPY --from=daimon_package /dependencies.tar /tmp/dependencies.tar -COPY --from=daimon_package /source-inputs.json /tmp/source-inputs.json -COPY --from=daimon_package /grok /tmp/offline-grok -COPY --from=daimon_package /agy.tar.gz /tmp/offline-agy.tar.gz - RUN test -n "${GROK_CLI_VERSION}" \ && test -n "${GROK_CLI_URL}" \ && test -n "${GROK_CLI_SHA256}" \ @@ -58,28 +136,6 @@ RUN test -n "${GROK_CLI_VERSION}" \ && { test "${DAIMON_DEPENDENCY_MODE}" = registry || test "${DAIMON_DEPENDENCY_MODE}" = offline-bundle; } \ && test -n "${CODEX_CLI_SHA256}" \ && test -n "${TARGETARCH}" \ - && test "$(sha256sum /tmp/daimon.tgz | awk '{print "sha256:" $1}')" = "${DAIMON_PACKAGE_SHA256}" \ - && if test "${DAIMON_DEPENDENCY_MODE}" = registry; then apt-get update && apt-get install --yes --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*; fi \ - && mkdir -p ${RUNTIME_ROOT}/bin \ - && cd ${RUNTIME_ROOT} \ - && if test "${DAIMON_DEPENDENCY_MODE}" = offline-bundle; then \ - test "$(sha256sum /tmp/dependencies.tar | awk '{print "sha256:" $1}')" = "${DAIMON_DEPENDENCY_ARCHIVE_SHA256}" \ - && mkdir -p node_modules/@noopolis/daimon \ - && tar -xf /tmp/dependencies.tar -C node_modules \ - && tar -xzf /tmp/daimon.tgz -C node_modules/@noopolis/daimon --strip-components=1 \ - && test -x node_modules/@openai/codex/bin/codex.js; \ - else npm install --omit=dev --no-fund --no-audit /tmp/daimon.tgz @openai/codex@${CODEX_CLI_VERSION}; fi \ - && if test "${DAIMON_DEPENDENCY_MODE}" = offline-bundle; then cp /tmp/offline-grok /tmp/grok; else curl -fsSL "${GROK_CLI_URL}" -o /tmp/grok; fi \ - && echo "${GROK_CLI_SHA256} /tmp/grok" | sha256sum -c - \ - && install -m 0755 /tmp/grok ${RUNTIME_ROOT}/bin/grok \ - && if test "${DAIMON_DEPENDENCY_MODE}" = offline-bundle; then cp /tmp/offline-agy.tar.gz /tmp/agy.tar.gz; else curl -fsSL "${AGY_CLI_URL}" -o /tmp/agy.tar.gz; fi \ - && echo "${AGY_CLI_SHA512} /tmp/agy.tar.gz" | sha512sum -c - \ - && rm -rf /tmp/agy-extract \ - && mkdir -p /tmp/agy-extract \ - && tar -xzf /tmp/agy.tar.gz -C /tmp/agy-extract \ - && agy_path="$(find /tmp/agy-extract -type f -name antigravity -print -quit)" \ - && test -n "${agy_path}" \ - && install -m 0755 "${agy_path}" ${RUNTIME_ROOT}/bin/agy \ && test -x ${RUNTIME_ROOT}/node_modules/@openai/codex/bin/codex.js \ && test -f ${RUNTIME_ROOT}/node_modules/@noopolis/daimon/dist/runtime/cli.js \ && case "${TARGETARCH}" in \ @@ -108,8 +164,6 @@ RUN test -n "${GROK_CLI_VERSION}" \ && test "$(sha256sum ${RUNTIME_ROOT}/bin/daimon-engine-broker | awk '{print $1}')" = "${broker_sha}" \ && test "$(sha256sum ${RUNTIME_ROOT}/bin/agy | awk '{print "sha256:" $1}')" = "sha256:${AGY_CLI_SHA256#sha256:}" \ && 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} \ - && rm -rf /tmp/agy.tar.gz /tmp/agy-extract /tmp/grok \ - && npm cache clean --force \ && test -f ${RUNTIME_ROOT}/node_modules/@noopolis/daimon/package.json FROM scratch diff --git a/scripts/build-local-daimon-runtime.mjs b/scripts/build-local-daimon-runtime.mjs index 82108ce5..d7636fa2 100644 --- a/scripts/build-local-daimon-runtime.mjs +++ b/scripts/build-local-daimon-runtime.mjs @@ -88,11 +88,16 @@ export const resolveLocalImageTag = (value) => { return tag; }; -export const resolveLocalBuildArchitecture = (hostArchitecture) => { +export const resolveLocalBuildArchitecture = (hostArchitecture, requested = process.env.SPAWNFILE_DAIMON_TARGET_ARCH) => { if (hostArchitecture !== "x64" && hostArchitecture !== "arm64") { - throw new Error("Local Daimon builds require an x64 or arm64 Docker host for the linux/amd64 artifact"); + throw new Error("Local Daimon builds require an x64 or arm64 Docker host"); } - return "amd64"; + if (requested !== undefined && requested !== "amd64" && requested !== "arm64") { + throw new Error(`Unsupported SPAWNFILE_DAIMON_TARGET_ARCH: ${requested}`); + } + // Default to the host architecture. Emulating amd64 on an arm64 host cannot create the + // user namespaces the Grok sandbox requires, so a native build is the working default. + return requested ?? (hostArchitecture === "arm64" ? "arm64" : "amd64"); }; export const resolvePushedImageReference = (imageTag, repoDigests) => { @@ -159,13 +164,13 @@ const stageOfflineCliAssets = (directory, artifacts) => { } }; -const stageBundleBuiltDaimon = (directory, artifacts) => { +const stageBundleBuiltDaimon = (directory, artifacts, architecture) => { const sourceDirectory = path.join(directory, "source_bundle"), dependencyDirectory = path.join(directory, "dependency_bundle"); mkdirSync(sourceDirectory, { recursive: true }); mkdirSync(dependencyDirectory, { recursive: true }); const source = requiredBundle("SPAWNFILE_DAIMON_SOURCE_BUNDLE", "source.tar", sourceDirectory, "source"); const dependencies = requiredBundle("SPAWNFILE_DAIMON_DEPENDENCY_BUNDLE", "dependencies.tar", dependencyDirectory, "dependencies"); const output = path.join(directory, "package"); mkdirSync(output, { recursive: true }); - execFileSync("docker", ["build", "--network=none", "--platform", "linux/amd64", "--build-context", `source_bundle=${sourceDirectory}`, + execFileSync("docker", ["build", "--network=none", "--platform", `linux/${architecture}`, "--build-context", `source_bundle=${sourceDirectory}`, "--build-context", `dependency_bundle=${dependencyDirectory}`, "--output", `type=local,dest=${output}`, "--build-arg", `SOURCE_ARCHIVE_SHA256=${source.archive_sha256}`, "--build-arg", `DEPENDENCY_ARCHIVE_SHA256=${dependencies.archive_sha256}`, "--build-arg", `SOURCE_MANIFEST_SHA256=${source.manifest_sha256}`, "--build-arg", `DEPENDENCY_MANIFEST_SHA256=${dependencies.manifest_sha256}`, @@ -181,7 +186,7 @@ const stageBundleBuiltDaimon = (directory, artifacts) => { mode: "source-bundle", source: { archive_sha256: source.archive_sha256, manifest_sha256: source.manifest_sha256 }, version: "spawnfile.daimon-source-inputs.v1" }; const buildIdentity = JSON.parse(readFileSync(path.join(output, "source-inputs.json"), "utf8")); - if (buildIdentity.target !== "linux/amd64" || buildIdentity.source.archive_sha256 !== source.archive_sha256 || buildIdentity.source.manifest_sha256 !== source.manifest_sha256 || + if (buildIdentity.target !== `linux/${architecture}` || buildIdentity.source.archive_sha256 !== source.archive_sha256 || buildIdentity.source.manifest_sha256 !== source.manifest_sha256 || buildIdentity.dependencies.archive_sha256 !== dependencies.archive_sha256 || buildIdentity.dependencies.manifest_sha256 !== dependencies.manifest_sha256) { throw new Error("Remote bundle build source identity does not match its attested inputs"); } @@ -234,7 +239,7 @@ const main = () => { const artifacts = readDaimonCliArtifactPins(); const packageDirectory = mkdtempSync(path.join(os.tmpdir(), "spawnfile-daimon-package-")); try { - const bundled = sourceMode === "source-bundle" ? stageBundleBuiltDaimon(packageDirectory, artifacts) : null; + const bundled = sourceMode === "source-bundle" ? stageBundleBuiltDaimon(packageDirectory, artifacts, architecture) : null; const packagePath = bundled?.packagePath ?? stagePackagedDaimon(packageDirectory); const manifestBytes = execFileSync("tar", ["-xOf", packagePath, "package/dist/runtime/contract-manifest.json"]); const receipt = createLocalDaimonCapabilityReceipt({ diff --git a/scripts/build-local-daimon-runtime.test.mjs b/scripts/build-local-daimon-runtime.test.mjs index ee6f2f35..61fc0da0 100644 --- a/scripts/build-local-daimon-runtime.test.mjs +++ b/scripts/build-local-daimon-runtime.test.mjs @@ -82,10 +82,23 @@ test("local image authority accepts an ephemeral loopback registry and immutable assert.throws(() => resolvePushedImageReference(tag, []), /manifest digest/u); }); -test("local build architecture fails closed outside the official AGY linux_amd64 target", () => { +test("local build architecture defaults to the host and fails closed on anything unbuildable", () => { + // Native by default: emulating amd64 on an arm64 host cannot create the user + // namespaces the Grok sandbox requires, so the host architecture wins unless + // SPAWNFILE_DAIMON_TARGET_ARCH explicitly asks for the other one. assert.equal(resolveLocalBuildArchitecture("x64"), "amd64"); - assert.equal(resolveLocalBuildArchitecture("arm64"), "amd64"); - assert.throws(() => resolveLocalBuildArchitecture("riscv64"), /linux\/amd64/u); + assert.equal(resolveLocalBuildArchitecture("arm64"), "arm64"); + + // The explicit override is honoured in both directions. + assert.equal(resolveLocalBuildArchitecture("arm64", "amd64"), "amd64"); + assert.equal(resolveLocalBuildArchitecture("x64", "arm64"), "arm64"); + + // Both fail-closed edges: an unbuildable host, and an unsupported request. + assert.throws(() => resolveLocalBuildArchitecture("riscv64"), /x64 or arm64/u); + assert.throws( + () => resolveLocalBuildArchitecture("x64", "riscv64"), + /Unsupported SPAWNFILE_DAIMON_TARGET_ARCH/u + ); }); test("archive provenance is explicit and cannot silently fall back to Git", () => { @@ -207,18 +220,30 @@ test("dependency lock truth rejects near-empty graphs and a fake Codex version", test("Daimon Dockerfile verifies the AGY archive before extracting antigravity and verifies every installed executable", () => { const dockerfile = readFileSync(new URL("../runtime-images/daimon/Dockerfile", import.meta.url), "utf8"); assert.match(dockerfile, /^ARG NODE_BASE_IMAGE=node:24-bookworm-slim@sha256:[a-f0-9]{64}\nFROM daimon_package AS daimon_package/mu); - assert.match(dockerfile, /FROM \$\{NODE_BASE_IMAGE\} AS build/u); + assert.match(dockerfile, /FROM base_\$\{DAIMON_DEPENDENCY_MODE\} AS base/u); + assert.match(dockerfile, /FROM grok_source_\$\{DAIMON_DEPENDENCY_MODE\} AS grok_cli/u); + assert.match(dockerfile, /FROM agy_source_\$\{DAIMON_DEPENDENCY_MODE\} AS agy_cli/u); + assert.match(dockerfile, /FROM daimon_\$\{DAIMON_DEPENDENCY_MODE\} AS build/u); const archiveDownload = dockerfile.indexOf('curl -fsSL "${AGY_CLI_URL}" -o /tmp/agy.tar.gz'); const archiveVerification = dockerfile.indexOf("sha512sum -c -"); const archiveExtraction = dockerfile.indexOf("tar -xzf /tmp/agy.tar.gz"); const executableLookup = dockerfile.indexOf("-name antigravity"); const executableInstall = dockerfile.indexOf('install -m 0755 "${agy_path}"'); + const grokInstall = dockerfile.indexOf("install -m 0755 /tmp/grok"); + const codexInstall = dockerfile.indexOf("npm install --omit=dev --no-fund --no-audit @openai/codex@"); + const daimonCopy = dockerfile.indexOf("COPY --from=daimon_package /daimon.tgz /tmp/daimon.tgz", dockerfile.indexOf("FROM codex_registry AS daimon_registry")); + const offlineDaimonCopy = dockerfile.indexOf("COPY --from=daimon_package /daimon.tgz /tmp/daimon.tgz", dockerfile.indexOf("FROM codex_offline-bundle AS daimon_offline-bundle")); assert.ok(archiveDownload >= 0); assert.ok(archiveDownload < archiveVerification); assert.ok(archiveVerification < archiveExtraction); assert.ok(archiveExtraction < executableLookup); assert.ok(executableLookup < executableInstall); + assert.ok(grokInstall < daimonCopy); + assert.ok(executableInstall < daimonCopy); + assert.ok(codexInstall < daimonCopy); + assert.ok(grokInstall < offlineDaimonCopy); + assert.ok(executableInstall < offlineDaimonCopy); assert.match(dockerfile, /sha256sum \$\{RUNTIME_ROOT\}\/bin\/agy/u); assert.match(dockerfile, /sha256sum \$\{RUNTIME_ROOT\}\/bin\/grok/u); assert.match(dockerfile, /sha256sum \$\{RUNTIME_ROOT\}\/node_modules\/@openai\/codex\/bin\/codex\.js/u); @@ -235,5 +260,69 @@ test("Daimon Dockerfile verifies the AGY archive before extracting antigravity a assert.match(dockerfile, /DAIMON_DEPENDENCY_MODE.*offline-bundle/su); assert.match(dockerfile, /sha256sum \/tmp\/dependencies\.tar/u); assert.match(dockerfile, /source_inputs\?\.dependencies\?\.runtime_archive_sha256/u); - assert.match(dockerfile, /tar -xf \/tmp\/dependencies\.tar -C node_modules/u); + assert.match(dockerfile, /tar -xf \/tmp\/dependencies\.tar -C \$\{RUNTIME_ROOT\}\/node_modules/u); + assert.match(dockerfile, /FROM \$\{NODE_BASE_IMAGE\} AS base_offline-bundle/u); + assert.match(dockerfile, /FROM \$\{NODE_BASE_IMAGE\} AS base_registry/u); + assert.match(dockerfile, /FROM codex_registry AS daimon_registry/u); + assert.match(dockerfile, /FROM codex_offline-bundle AS daimon_offline-bundle/u); + assert.match(dockerfile, /--mount=type=cache,target=\/root\/\.npm,sharing=locked/u); + assert.doesNotMatch(dockerfile, /npm cache clean/u); +}); + +test("Daimon Dockerfile stage graph preserves cache and offline-network boundaries", () => { + const dockerfile = readFileSync(new URL("../runtime-images/daimon/Dockerfile", import.meta.url), "utf8"); + const stages = new Map(); + const fromPattern = /^FROM\s+(\S+)\s+AS\s+(\S+)\s*$/gimu; + const declarations = [...dockerfile.matchAll(fromPattern)]; + + for (const [index, declaration] of declarations.entries()) { + const [, rawParent, name] = declaration; + const parent = rawParent.replaceAll("${DAIMON_DEPENDENCY_MODE}", "registry"); + const bodyStart = declaration.index + declaration[0].length; + const bodyEnd = declarations[index + 1]?.index ?? dockerfile.length; + stages.set(name, { body: dockerfile.slice(bodyStart, bodyEnd), parent }); + } + + const ancestry = (graph, target) => { + const chain = []; + const visited = new Set(); + let current = target; + while (graph.has(current)) { + assert.ok(!visited.has(current), `stage ancestry must not contain a cycle at ${current}`); + visited.add(current); + chain.push(current); + current = graph.get(current).parent; + } + chain.push(current); + return chain; + }; + + assert.deepEqual(ancestry(stages, "build"), [ + "build", "daimon_registry", "codex_registry", "agy_cli", "agy_source_registry", + "grok_cli", "grok_source_registry", "base", "base_registry", "${NODE_BASE_IMAGE}" + ]); + + const offlineStages = new Map([...stages].map(([name, stage]) => [ + name, + { ...stage, parent: stage.parent.replaceAll("registry", "offline-bundle") } + ])); + assert.deepEqual(ancestry(offlineStages, "build"), [ + "build", "daimon_offline-bundle", "codex_offline-bundle", "agy_cli", "agy_source_offline-bundle", + "grok_cli", "grok_source_offline-bundle", "base", "base_offline-bundle", "${NODE_BASE_IMAGE}" + ]); + + const assertAncestorsExcludeDaimonInputs = (graph, target) => { + for (const ancestor of ancestry(graph, target).slice(1, -1)) { + assert.doesNotMatch(graph.get(ancestor).body, /daimon\.tgz|source-inputs\.json/u, `${ancestor} must not depend on Daimon package inputs`); + } + }; + assertAncestorsExcludeDaimonInputs(stages, "daimon_registry"); + assertAncestorsExcludeDaimonInputs(offlineStages, "daimon_offline-bundle"); + + const stagesContaining = (pattern) => [...stages] + .filter(([, stage]) => pattern.test(stage.body)) + .map(([name]) => name) + .sort(); + assert.deepEqual(stagesContaining(/\bapt-get\b/u), ["base_registry"]); + assert.deepEqual(stagesContaining(/\bcurl\s+-/u), ["agy_source_registry", "grok_source_registry"]); }); diff --git a/specs/AGENTS.md b/specs/AGENTS.md index dcfad71d..df2162bd 100644 --- a/specs/AGENTS.md +++ b/specs/AGENTS.md @@ -11,6 +11,7 @@ specs/ ├── RUNTIMES.md # Runtime registry, version pinning, adapter lifecycle ├── CAUSAL.md # Shared causal wire and Stele read/verify contract ├── ECOSYSTEM_RUNTIME_BOUNDARIES.md # Cross-project runtime authority and enforcement gates +├── USAGE_ACCOUNTING_DESIGN.md # Daimon turn-usage envelope and Spawnfile aggregation design ├── research/ │ ├── AUTH-NOTES.md # Authentication research and implementation notes │ ├── DIRECT-SURFACES.md # Direct protocol surface research diff --git a/specs/INDEX.md b/specs/INDEX.md index a46e9ab3..752fab58 100644 --- a/specs/INDEX.md +++ b/specs/INDEX.md @@ -20,6 +20,7 @@ These are the source of truth. Implementation in `src/` must stay aligned with t | [CAUSAL.md](CAUSAL.md) | evolving | Causal event envelope — producer wire rules plus the shared Stele read/verify and reconciliation contract | | [TARGETS.md](TARGETS.md) | evolving | Project-neutral target-resource public contracts and staged target-adapter boundary | | [ECOSYSTEM_RUNTIME_BOUNDARIES.md](ECOSYSTEM_RUNTIME_BOUNDARIES.md) | normative, evolving | Cross-project runtime authority — lifecycle composition, autonomous agents, world execution, provider ownership, Stele verification, senses/actions/MCP, and enforcement gates | +| [USAGE_ACCOUNTING_DESIGN.md](USAGE_ACCOUNTING_DESIGN.md) | design, not yet implemented | Usage accounting — the `noopolis.daimon.turn-usage.v1` wire envelope, Daimon-side measurement and ledger, and Spawnfile-side transport, aggregation, and `spawnfile usage` CLI surface | ## Research @@ -52,6 +53,9 @@ ECOSYSTEM_RUNTIME_BOUNDARIES.md ← normative ownership constraints across Spawnfile, Simfile, Daimon, Moltnet, Mneme, and Stele +USAGE_ACCOUNTING_DESIGN.md ← turn-usage envelope produced by Daimon, read and + aggregated by Spawnfile's usage command + research/RUNTIME-NOTES.md ← informs adapter implementation and RUNTIMES.md research/AUTH-NOTES.md ← informs auth/profile UX, per-model auth/endpoint config, and future surface provisioning research/DIRECT-SURFACES.md ← informs direct `http` / `webhook` / `a2a` surface design and future shared-network compatibility diff --git a/specs/USAGE_ACCOUNTING_DESIGN.md b/specs/USAGE_ACCOUNTING_DESIGN.md new file mode 100644 index 00000000..68923447 --- /dev/null +++ b/specs/USAGE_ACCOUNTING_DESIGN.md @@ -0,0 +1,302 @@ +# Usage accounting — Daimon measures, Spawnfile aggregates + +*Revision 3. Two independent reviewers (fable, agy) rejected revision 2 on the +same two defects, found by tracing container machinery the design never named. +Findings folded in at the end.* + +## Why + +On 2026-08-28 a Grok subscription was exhausted without publishing anything. +Reconstructing the spend afterwards was impossible: both engines report token +usage every turn and Daimon discards it at `cliSession.ts:324`, where a `Usage` +struct is emitted hardcoded to zeros. Turn counts survived in the broker +receipts; token counts existed nowhere. + +Measured on the live container: + + codex exec --json "say OK" → input 12,346 · cached 5,504 · output 5 + grok --output-format json "say OK" → input 8,746 · cached 5,760 · output 29 + costUSD 0.0035 + +~10k tokens of fixed context per turn before any work, and 2,481 wakes in 24h. +Nothing in the system could say so at the time. + +## Boundary + +Usage is a **runtime** fact, not a product one. + + daimon measures one turn agent · wake · engine · tokens + spawnfile aggregates the org by agent, by engine, over a window + clank correlates if it wants maps its own event_keys to wake ids + +No Daimon or Spawnfile artifact names an edition or any other Clank concept. +`specs/ECOSYSTEM_RUNTIME_BOUNDARIES.md:153` already assigns per-agent turn +telemetry to the agent runtime, so Spawnfile aggregating it through a versioned +artifact is the intended contract shape, not a boundary breach. + +## Route + +Production Grok does **not** take the direct-spawn path. Verified: + + organizationRuntimeReadiness.ts:29-33 any grok agent → broker, unconditionally + engineDispatcher.ts:93 wires grokBrokerTurn + cliSession.ts:258-260 broker branch returns before line 289 + grokEngineBroker.ts:24 ← decodeGrokHeadlessResult runs HERE + +Extraction goes in the decoder (shared by both callers). Persistence goes in the +broker, in the **success branch only** — `finish()` also runs in the catch path, +and a completed replay returns at `grokEngineBroker.ts:21` before the try block, +so a call placed after the success `finish()` structurally cannot double-count. + +### Ruled out, with reasons + +- **Inside the `completed` frame.** The durable record is re-validated by the + strict wire parser (`engineBrokerTurnRegistry.ts:22` → `parseEngineBrokerResponse`, + exact field set at `engineBrokerProtocol.ts:55`). An extra field makes the next + `begin()` throw permanently — crash-recovery replay breaks. Proven by probe test. +- **Returned from `turn()`.** `engineBrokerService.ts:20` spreads the result onto + the wire; the strict client would reject every completed turn. +- **An unprovisioned path.** Revision 1 chose `/var/lib/spawnfile/daimon/usage/` + without creating it. The writer is pinned to uid 2100 + (`engineBrokerServiceCli.ts:10`); only the realm dir is provisioned + (`containerDaimonBrokerRender.ts:83`); the parent is a root-owned mountpoint. + `mkdir` returns EACCES, and because writes are advisory the ledger would be + **silently empty forever**. + +## Artifact + +A dedicated persistent volume, provisioned like the realm, owned by the broker. + + /var/lib/spawnfile/daimon/usage/ 0750, chown 2100:2100, root-provisioned + usage.jsonl 0640, append-only + +Three changes make it real, all previously missing: + +1. `containerDaimonBrokerRender.ts` — a `mkdirSync` + `chownSync(2100,2100)` + + `chmodSync(0o750)` line beside the existing realm line. +2. `spawnfile/src/runtime/daimon/config.ts` — a `persistentMounts` entry, so the + ledger survives redeploy. Without it the file lives in the container writable + layer and dies exactly when the incident report is needed. +3. `containerDaimonBrokerRender.ts:92` — a worker deny-list entry. Peer isolation + is an explicit posture there; a readable ledger would leak every agent's wake + ids and cadence to every sandboxed worker. +4. **`containerDaimonUidEntrypointRender.ts:103-111` — exclude the usage mount + from `state_roots`.** This is the one revision 2 missed and it is fatal without + it. The ownership guard runs *before* broker provisioning (guard at `:270-281`, + provisioning spliced at `:286`) and recursively chowns every persistent mount + to uid **2000** on every container start (`containerDaimonOwnershipGuardRender.ts:115-126`). + The realm survives only because of a hardcoded exact-path exclusion at `:111`. + Without a matching exclusion: boot 1 works, and from boot 2 onward the guard + chowns `usage.jsonl` to 2000, the broker's `O_APPEND` gets EACCES, the advisory + posture swallows it, and the ledger is **silently empty forever** — the same + hole as revision 1, one boot later. Add a boot-time write probe beside the + existing realm probes (`:300-306`) so the failure is loud rather than silent. + +**The record carries no `org`.** Revision 1 put one in, which would have forced a +5th key into a config parser that asserts exactly 4 +(`engineBrokerServiceCli.ts:20`) plus both contract manifests. Unnecessary: one +ledger per container, one organization per container, so identity comes from +*which container was queried*. + +```json +{"v":"noopolis.daimon.turn-usage.v1", + "agent":"cogsworth","wake":"…","engine":"grok","at":"2026-08-29T01:12:04Z", + "input":8746,"output":29,"cache_read":5760,"cache_write":0, + "total":20535,"calls":1,"notional_usd":0.0035,"complete":true} +``` + +- **Numeric-only, plus `agent` and `wake`.** No engine-controlled string is ever + persisted — which is why `perModel` is **dropped**: its keys are model names + chosen by the engine. Grok reports `costUSD` itself, so no rate table is lost. +- **`wake` is caller-supplied, not Daimon-owned.** It is `event.id` from the wake + request (`piAgentHandle.ts:157`), schema-bounded to 4096 codepoints + (`organizationRuntimeContract.ts:7`), fixed before the engine runs — so it is + engine-free, but it is external text. **Truncate to 128 characters** in the + ledger. JSON escaping already prevents line injection. +- **`total` includes cache**, matching the pi-ai `Usage` convention. Cached + context is the dominant cost; excluding it hides the thing being measured. +- **`complete:false` is a heuristic, and its blind spot is stated.** Production + hardcodes `streaming-messages-json` (`engineBrokerLauncherCore.inc:326`). No + fixture in either repo records what that format's `result` frame actually + carries, and a zero-filled usage block is **byte-indistinguishable** from a real + one — "the engine zero-filled" is not a wire-observable event. The rule is + therefore: `complete:false` when the usage block is absent, or when + `input == 0` (no real turn has zero input tokens). **This cannot catch the + partial case** — a multi-model turn where one entry is zero-filled sums to a + plausible nonzero total and is stamped `complete:true` while undercounting. + Capture a real `result` frame as a fixture before implementing, and if the + format proves to carry no completeness signal at all, label every count a lower + bound rather than claiming detection. +- **`notional_usd`, never `cost`.** Flat subscriptions; nothing is billed. +- **Advisory.** A malformed usage block, or a failed append, writes no line and + never fails a turn that published. + +### Append discipline + +One writer process per container (the single broker service), so plain `O_APPEND` +is correct and temp-write-rename would be wrong for an append-only file. Two +rules make it safe, both testable: + +- **One `write(2)` per complete line**, since turns for different agents run + concurrently inside the broker (`grokEngineBroker.ts:16`). +- **The reader skips any unparseable line and any unterminated trailing line** — + a crash mid-append leaves a torn final record. +- **The append is wrapped in its own try/catch.** The insertion point sits inside + the try block whose catch calls `finish(..., failed)` unconditionally + (`grokEngineBroker.ts:25`), and `finish` has no terminal-state guard — it + renames over an existing record (`engineBrokerTurnRegistry.ts:26-31`). An + escaping append error would rewrite an already-*completed* turn as *failed*. + This is the one place where getting "advisory" wrong corrupts turn state instead + of dropping a line, so it gets its own mutation test. + +Rotate at 64 MB to a `.1` sibling, keeping one generation. ~2.5k lines/day +observed, so that is months. + +## Transport — how Spawnfile reads it + +The ledger is inside the container. `spawnfile usage` runs on the host, which on +this dev machine is macOS Docker Desktop, and may target a remote Docker context. +A host-side `readFile` is impossible in both cases. + +Reads go through the **existing sanctioned channel**: the docker probe gateway +(`spawnfile/src/deployment/dockerProbeGateway.ts:122-135`), which already supports +`--context` / `--host` remote targets via `withDockerTarget` (lines 39-50) and is +already used to `cat` container files (`dockerManager.ts:324-333`). `docker exec` +runs as the image user — root for a Daimon image +(`containerArtifactsRender.ts:296`) — so a 0640 uid-2100 file is readable. + + spawnfile usage → dockerProbeGateway → docker exec → cat usage.jsonl → parse → group + +Three constraints the channel imposes, all missed by revision 2: + +- **The gateway cannot return more than 1 MiB.** It calls `execFile` with only + `{ timeout }` (`dockerProbeGateway.ts:14`, `:125-132`), and its injectable type + admits no other option, so Node's 1 MiB `maxBuffer` default applies. At ~575 KB + of ledger per day the `cat` starts failing on **day two**. The gateway's + contract must gain a `maxBuffer`, or the reader must stream/tail. This is a + change to the channel, not merely a new caller of it. +- **A stopped container has no `docker exec`.** The scenario that motivated this + feature is a post-mortem, and `spawnfile down` deliberately preserves volumes. + So the reader falls back to the repo's own volume-egress pattern — `docker + create` from the deployment image plus `docker cp`, as `artifactsExportDocker.ts` + already does — whenever exec is unavailable. +- **A fresh ledger does not exist.** `cat` returns ENOENT and a non-zero exit + before the first turn ever completes; `spawnfile usage` must render that as an + empty ledger, never as an error. + +Rotation means the reader must read **both generations** (`usage.jsonl` and +`usage.jsonl.1`), or a `--since` window spanning a rotation silently loses lines. + +## Surface + +A new command. `status` answers *is it healthy*; usage answers *what did it +consume* — different question, different cadence, and `status` must not read a +growing ledger on every invocation. + + spawnfile usage org total, last 24h + spawnfile usage --since 7d window + spawnfile usage --by agent default + spawnfile usage --by engine rollup + spawnfile usage --agent cogsworth one agent + spawnfile usage --top 5 the runaway, immediately + spawnfile usage --json machine-readable + +``` +ORG daimon-organization · last 24h · coverage PARTIAL (10 of 16 agents) + +agent engine turns tokens notional share +cogsworth grok 121 2.1M $6.80 34% +foreman grok 78 1.4M $4.30 22% +brass codex — — — — +────────────────────────────────────────────────────────── +grok 451 5.9M $18.85 +codex — — — +``` + +**`status` gets no usage line.** Revision 1 added a pointer showing live 24h +aggregates, which would have required exactly the read it forbade. + +**Coverage is always explicit.** With Codex uninstrumented, six of sixteen agents +report nothing, so a total is labelled `PARTIAL` and never presented as the org's +cost. + +## Scope + +**In:** Grok — ten of sixteen agents, and the account that actually died. + +**Out — Codex.** `--json` changes how every Codex reply is extracted +(`readChild`'s last-64KB-of-stdout → the `item.completed` agent_message) inside +`cliSession.ts`, a file named in the outstanding A2 P1 findings. Belongs with A3. + +**Out — quota percentages.** Neither CLI exposes the denominator: codex `--json` +has no rate-limit event, `logs_2.sqlite` has no rate-limit columns, grok's only +usage-adjacent command is `du`. + +## Files + + daimon/src/pi/grokHeadlessResult.ts extract usage; supersede the dead draft + daimon/src/runtime/turnUsageLedger.ts new — append one line, advisory + daimon/src/runtime/grokEngineBroker.ts call it in the success branch + daimon/src/contracts/runtimeContractManifest.ts register turn-usage.v1 + spawnfile/src/compiler/containerDaimonBrokerRender.ts provision dir + deny-list entry + spawnfile/src/compiler/containerDaimonUidEntrypointRender.ts exclude usage from state_roots + write probe + spawnfile/src/deployment/dockerProbeGateway.ts maxBuffer (or a streaming read) + spawnfile/src/runtime/daimon/config.ts persistent mount + spawnfile/src/runtime/daimon/contractManifest.ts mirror turn-usage.v1 + spawnfile/src/runtime/usageLedger.ts new — read via probe gateway, window, group + spawnfile/src/cli/usageCommand.ts new — the command + +## Verification + +1. **Decoder** — summed usage; malformed/renamed/stringified field → `undefined` + and no throw; an `error` stream carrying plausible usage still throws; a canary + in the engine's usage block appears nowhere. +2. **Ledger** — one line per completed turn; a **replayed** turn writes no second + line; a failed append does not fail the turn; a torn trailing line is skipped + by the reader and the rest still parses. +3. **Provisioning** — the render emits the mkdir/chown/chmod and the deny-list + entry; the mount appears in `persistentMounts`; the usage path is **absent** + from the entrypoint's `state_roots`; a second simulated boot leaves the ledger + owned by 2100 and writable. +4. **Transport** — a ledger larger than 1 MiB is read successfully; a stopped + container falls back to volume egress; a missing ledger renders as empty; + a window spanning a rotation includes the rotated generation. +5. **Aggregation** — grouping by agent and by engine; window filtering; `PARTIAL` + coverage when an engine reports nothing. +6. **Mutation** — delete the cache terms from `total`; delete the + malformed-rejection guard; delete the replay suppression; delete the + torn-line skip; delete the append's try/catch and assert a completed turn is + not rewritten as failed. Each must turn a test red. +7. **Regression** — targeted `src/pi` and `src/runtime`, then the full suite. + +## Findings folded in (revision 2 → 3) + +Found independently by two reviewers, by tracing container machinery: + +- **P0 ownership guard** — the guard chowns every persistent mount to 2000 on + every boot, before provisioning. The ledger would be silently unwritable from + the second boot onward. Fixed by a `state_roots` exclusion plus a write probe. +- **P1 1 MiB gateway ceiling** — `execFile` with no `maxBuffer`; reads break on + day two. The channel itself must change. +- **P2 stopped containers** — the post-mortem case has no `docker exec`; volume + egress added as fallback. +- **P2 `complete:false` unobservable** — downgraded to a stated heuristic with its + blind spot written down, and a fixture required before implementing. +- **P2 fresh ledger ENOENT** — rendered as empty, not an error. +- **P3 append error could clobber a terminal record** — own try/catch, own mutation. +- **P3 rotation dropped from queries** — read both generations. + +## Findings folded in (revision 1 → 2) + +- **P0 unwritable/unreachable ledger** — now provisioned, mounted, deny-listed, + and read through the probe gateway. +- **P2 broker cannot name the org** — `org` removed from the record entirely; + identity comes from the queried container. +- **P2 append discipline unspecified** — single-write rule, torn-line skip, and a + mutation for each. +- **P2 world-readable ledger leaked peer activity** — 0750/0640 plus a worker + deny-list entry. +- **P3 `wake` overstated as Daimon-owned** — corrected; truncated to 128 chars. +- **P3 `status` contradiction** — pointer line removed. +- **P3 no retention, no manifest entry** — 64 MB rotation, one generation; + schema registered in both contract manifests. diff --git a/src/compiler/compileProjectSupport.ts b/src/compiler/compileProjectSupport.ts index 94f463c0..5d09e2f3 100644 --- a/src/compiler/compileProjectSupport.ts +++ b/src/compiler/compileProjectSupport.ts @@ -179,6 +179,16 @@ export const injectMoltnetWorkspaceFiles = async ( } await writeEmittedFiles(runtimeOutputDirectory, moltnetClientConfigFiles); + // Daimon agents talk Moltnet through the native moltnet_send/moltnet_read + // tools, and the moltnet CLI is not authenticated inside a Daimon + // workspace. Installing its skill there only teaches the agent a shell + // path that fails before it falls back to the tools it already has, so + // Daimon skips the skill install entirely; .moltnet/config.json above is + // still emitted since the native tools read it. + if (compiled.value.runtime.name === "daimon") { + continue; + } + if (!moltnetCliCommand) { moltnetCliCommand = await resolveMoltnetCliCommand(); } diff --git a/src/compiler/containerArtifactsPlans.test.ts b/src/compiler/containerArtifactsPlans.test.ts index 57004daf..9b6f915e 100644 --- a/src/compiler/containerArtifactsPlans.test.ts +++ b/src/compiler/containerArtifactsPlans.test.ts @@ -185,6 +185,13 @@ describe("runtime target plan source identity", () => { mount_path: "/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/assistant/tool-state", reason: "Daimon durable cognition tool receipts for agent:assistant", volume_name: createPersistentVolumeName("/tmp/Spawnfile", "daimon-tool-state-assistant", "candidate-blue") + }, + { + id: "daimon-wake-fuse", + lifecycle: "exclusive-reattach", + mount_path: "/var/lib/spawnfile/daimon/wake-fuse", + reason: "Daimon durable wake-fuse admission ledger", + volume_name: createExclusiveReattachVolumeName("/tmp/Spawnfile\u0000compile", "daimon-wake-fuse") } ] })); @@ -267,6 +274,13 @@ describe("runtime target plan source identity", () => { mount_path: "/var/lib/spawnfile/instances/daimon/daimon-organization/runtime-homes/writer/tool-state", reason: "Daimon durable cognition tool receipts for agent:writer", volume_name: createPersistentVolumeName("/tmp/Spawnfile", "daimon-tool-state-writer") + }, + { + id: "daimon-wake-fuse", + lifecycle: "exclusive-reattach", + mount_path: "/var/lib/spawnfile/daimon/wake-fuse", + reason: "Daimon durable wake-fuse admission ledger", + volume_name: expect.stringMatching(/^spawnfile-exclusive-daimon-wake-fuse-[a-f0-9]{16}$/u) } ]); expect(JSON.stringify(result[0]?.persistentMounts)).not.toMatch(/daimon-inbound|access_token|refresh_token/u); diff --git a/src/compiler/containerDaimonBrokerRender.test.ts b/src/compiler/containerDaimonBrokerRender.test.ts index be9e4327..2e2713b9 100644 --- a/src/compiler/containerDaimonBrokerRender.test.ts +++ b/src/compiler/containerDaimonBrokerRender.test.ts @@ -8,7 +8,11 @@ import { describe, expect, it } from "vitest"; import { DAIMON_GROK_TURN_USAGE_LEDGER } from "../runtime/daimon/contractManifest.js"; import { + DAIMON_BROKER_REALM, + DAIMON_ORGANIZATION_STATE_DIRECTORY, + GROK_SANDBOX_DENY_PATHS, renderDaimonBrokerProvisioning, + renderDaimonUsageLedgerProvisioning, renderDaimonWorkspaceResourceSecurity } from "./containerDaimonBrokerRender.js"; @@ -29,6 +33,46 @@ describe("Daimon broker registration ABI", () => { }); }); +describe("Daimon broker registration directory provisioning", () => { + const plan = { + runtimeName: "daimon", + engineByNodeId: { "agent:grok": "grok" }, + instancePaths: { workspacePath: "/workspace" } + } as unknown as Parameters[0][number]; + + it("keeps the broker directory writable until its files are provisioned", () => { + const lines = renderDaimonBrokerProvisioning([plan]).join("\n").split("\n"); + const mkdirIndex = lines.findIndex((line) => line.includes( + "fs.mkdirSync('/etc/daimon-engine-broker', { recursive: true, mode: 0o700 })" + )); + const serviceWriteIndex = lines.findIndex((line) => line.includes( + "fs.writeFileSync('/etc/daimon-engine-broker/service.json'" + )); + const tightenIndex = lines.findIndex((line) => line === + "fs.chmodSync('/etc/daimon-engine-broker', 0o555);" + ); + + expect(mkdirIndex).toBeGreaterThanOrEqual(0); + expect(serviceWriteIndex).toBeGreaterThan(mkdirIndex); + expect(tightenIndex).toBeGreaterThan(serviceWriteIndex); + }); + + it("restores owner-write before removing tightened broker directories", () => { + const lines = renderDaimonBrokerProvisioning([plan]); + const etcLoosen = lines.indexOf( + "if [ -d /etc/daimon-engine-broker ]; then chmod u+rwx /etc/daimon-engine-broker; fi" + ); + const runLoosen = lines.indexOf( + "if [ -d /run/daimon-engine-broker ]; then chmod u+rwx /run/daimon-engine-broker; fi" + ); + const remove = lines.indexOf("rm -rf /etc/daimon-engine-broker /run/daimon-engine-broker"); + + expect(etcLoosen).toBeGreaterThanOrEqual(0); + expect(runLoosen).toBeGreaterThan(etcLoosen); + expect(remove).toBeGreaterThan(runLoosen); + }); +}); + describe("Daimon broker usage ledger provisioning", () => { const plan = { runtimeName: "daimon", @@ -36,22 +80,158 @@ describe("Daimon broker usage ledger provisioning", () => { instancePaths: { workspacePath: "/workspace" } } as unknown as Parameters[0][number]; - it("provisions the usage ledger directory alongside the realm", () => { - const program = renderDaimonBrokerProvisioning([plan]).join("\n"); + it("fixes the usage ledger directory group-writable so Codex/AGY's organization-uid process can also write it, unconditionally (not just alongside the realm)", () => { const { directoryPath } = DAIMON_GROK_TURN_USAGE_LEDGER; + const lines = renderDaimonUsageLedgerProvisioning(); + // chown-to-root, then chmod, then chown-to-final-owner last: never + // `install -d`'s create-then-chown-then-chmod order, which needs + // CAP_FOWNER (not granted; see runProject.ts) once ownership moves off root. + expect(lines).toContainEqual(`chown 0:0 ${directoryPath} && chmod 0770 ${directoryPath} && chown 2100:2000 ${directoryPath}`); + expect(lines.some((line) => line.includes("--reuid 2000") && line.includes(".daimon-usage-probe"))).toBe(true); + // Unlike the broker realm/registrations, this runs even with no Grok agent at all. + expect(renderDaimonBrokerProvisioning([])).toEqual([]); + }); + + it("never lists the usage ledger directory in the rendered sandbox deny list", () => { + // The usage ledger directory is unix-denied to every worker uid + // unconditionally by `renderDaimonUsageLedgerProvisioning` (0770, + // broker:organization — a worker uid never matches either), so Grok + // could never verify a mask over it and would refuse to start if it were + // still listed. See `GROK_SANDBOX_DENY_PATHS`'s doc comment. + const program = renderDaimonBrokerProvisioning([plan]).join("\n"); + const deniedPathsLine = program.split("\n").find((line) => line.includes("const deniedPaths =")); + expect(deniedPathsLine).toBeDefined(); + expect(deniedPathsLine).not.toContain(DAIMON_GROK_TURN_USAGE_LEDGER.directoryPath); + }); +}); + +describe("Grok sandbox deny list: only worker-openable paths", () => { + // Every candidate a worker might plausibly need masked from other than the + // organization state directory is already unix-denied unconditionally — + // by construction, elsewhere in this same provisioning script or its + // `renderDaimonUsageLedgerProvisioning` sibling. Listing an already-denied + // path adds no protection and breaks Grok 1.0.13+'s own startup + // verification (it opens every `deny` path to confirm its bwrap mount + // caused the denial, and can't tell a pre-existing denial from its own + // mask). Verified live: `setpriv --reuid --regid + // --clear-groups` against a running deployment could not open any + // registrations.bin peer home/workspace, the subscription realm, + // grok-bootstrap-auth, /run/daimon-engine-broker, or the usage ledger — + // only the organization state directory (world-readable, 0755) was + // actually openable. + const planWithAgents = (agentIds: string[]) => ({ + runtimeName: "daimon", + engineByNodeId: Object.fromEntries(agentIds.map((agentId) => [agentId, "grok"])), + instancePaths: { workspacePath: "/workspace" } + } as unknown as Parameters[0][number]); + + it("contains only the organization state directory, regardless of how many Grok agents are registered", () => { + for (const agentIds of [["agent:solo"], ["agent:cogsworth", "agent:foreman", "agent:graves"]]) { + const program = renderDaimonBrokerProvisioning([planWithAgents(agentIds)]).join("\n"); + const deniedPathsLine = program.split("\n").find((line) => line.includes("const deniedPaths =")); + expect(deniedPathsLine).toBe(`const deniedPaths = ${JSON.stringify([DAIMON_ORGANIZATION_STATE_DIRECTORY])};`); + } + }); + + it("never lists a peer worker's home or workspace, the subscription realm, or the bootstrap credential", () => { + const program = renderDaimonBrokerProvisioning([ + planWithAgents(["agent:cogsworth", "agent:foreman", "agent:graves"]) + ]).join("\n"); + const deniedPathsLine = program.split("\n").find((line) => line.includes("const deniedPaths =")); + expect(deniedPathsLine).toBeDefined(); + for (const forbidden of [ + "daimon-workers", + "/workspace/agents/cogsworth", + "/workspace/agents/foreman", + "/workspace/agents/graves", + DAIMON_BROKER_REALM, + "/var/lib/spawnfile/daimon/grok-bootstrap-auth", + "/run/daimon-engine-broker" + ]) { + expect(deniedPathsLine).not.toContain(forbidden); + } + }); + + it("keeps the exported deny-path constant in sync with what gets rendered", () => { + expect(GROK_SANDBOX_DENY_PATHS).toEqual([DAIMON_ORGANIZATION_STATE_DIRECTORY]); + expect(DAIMON_ORGANIZATION_STATE_DIRECTORY).toBe( + "/var/lib/spawnfile/instances/daimon/daimon-organization/state" + ); + }); +}); + +describe("Daimon root provisioning capability-safe ordering", () => { + const plan = { + runtimeName: "daimon", + engineByNodeId: { "agent:grok": "grok" }, + instancePaths: { workspacePath: "/workspace" } + } as unknown as Parameters[0][number]; + const render = () => renderDaimonBrokerProvisioning([plan]).join("\n"); + + it("lets grok write hook state but never replace the sandbox profile", () => { + const program = render(); + + // Grok creates hook registries under .grok to enforce its deny list, so the worker + // needs write access there. The sticky bit means it still cannot unlink or rename the + // root-owned sandbox.toml, which is what the profile attestation depends on. + expect(program).toContain("ensureDirectory(configRoot, 0, entry.uid, 0o1771)"); + expect(program).toContain("ensureExactFile(profilePath, profileFor(), 0, 0, 0o444)"); + }); + + it("tightens the worker config directory after its last file write", () => { + const program = render(); + const lastWrite = program.indexOf("ensureEventsFile(eventsPath, entry.uid)"); + const tighten = program.indexOf("ensureDirectory(configRoot, 0, entry.uid, 0o1771)", lastWrite); + + expect(lastWrite).toBeGreaterThanOrEqual(0); + expect(tighten).toBeGreaterThan(lastWrite); + }); + + it("hands the broker realm to its owner after the last atomic credential write", () => { + const program = render(); + const credentialWrites = program.indexOf("try { const journalPath ="); + const finalOwnership = program.indexOf( + "fs.chownSync('/var/lib/spawnfile/daimon/grok-subscription-realm', 2100, 2100)", + credentialWrites + ); + + expect(credentialWrites).toBeGreaterThanOrEqual(0); + expect(finalOwnership).toBeGreaterThan(credentialWrites); + }); + + it("reclaims, modes, and restores helper-managed inode ownership in that order", () => { + const program = render(); + + expect(program).toContain( + "fs.chownSync(target, 0, 0); fs.chmodSync(target, mode); fs.chownSync(target, uid, gid)" + ); expect(program).toContain( - `fs.mkdirSync('${directoryPath}', { recursive: true, mode: 0o750 }); fs.chownSync('${directoryPath}', 2100, 2100); fs.chmodSync('${directoryPath}', 0o750);` + "fs.chownSync(target, 0, 0); fs.chmodSync(target, 0o640); fs.chownSync(target, uid, 2100)" ); }); - it("denies worker access to the usage ledger directory", () => { - const program = renderDaimonBrokerProvisioning([plan]).join("\n"); - const deniedForLine = program.split("\n").find((line) => line.includes("const deniedFor =")); - expect(deniedForLine).toBeDefined(); - expect(deniedForLine).toContain( - `'/var/lib/spawnfile/instances/daimon/daimon-organization/state', '${DAIMON_GROK_TURN_USAGE_LEDGER.directoryPath}']);` + it("modes atomic files and workspace nodes before final ownership", () => { + const program = render(); + + expect(program).toContain( + "fs.fchmodSync(output, 0o600); fs.fchownSync(output, 2100, 2100)" + ); + expect(program).toContain( + "fs.chownSync(target, 0, 0); fs.chmodSync(target, 0o640); fs.chownSync(target, 2000, uid)" ); }); + + it("restores the journal directory only after credential journal writes", () => { + const program = render(); + const write = program.indexOf("atomicOwned(journalPath, recovered)"); + const restore = program.indexOf( + "fs.chmodSync(journalRoot, 0o700); fs.chownSync(journalRoot, 2100, 2100)", + write + ); + + expect(write).toBeGreaterThanOrEqual(0); + expect(restore).toBeGreaterThan(write); + }); }); const validate = async (root: string, resource: Parameters[0][number], linkPath: string, expectedOwners = owners, infoOverride: Record = {}, pathOverrides:Record>={},secondFstatOverride:Record={}) => { diff --git a/src/compiler/containerDaimonBrokerRender.ts b/src/compiler/containerDaimonBrokerRender.ts index 56c1689f..3eb4e264 100644 --- a/src/compiler/containerDaimonBrokerRender.ts +++ b/src/compiler/containerDaimonBrokerRender.ts @@ -1,8 +1,10 @@ import path from "node:path"; +import { DAIMON_ORGANIZATION_TARGET_ID } from "../runtime/daimon/config.js"; import { DAIMON_GROK_ENGINE_BROKER, - DAIMON_GROK_TURN_USAGE_LEDGER + DAIMON_GROK_TURN_USAGE_LEDGER, + DAIMON_RUNTIME_HOME_ROOT } from "../runtime/daimon/contractManifest.js"; import type { RuntimeTargetPlan } from "./containerArtifactsTypes.js"; @@ -17,7 +19,48 @@ export const DAIMON_BROKER_LAUNCHER_SOCKET = "/run/daimon-engine-broker/launcher export const DAIMON_BROKER_SERVICE_CONFIG = "/etc/daimon-engine-broker/service.json"; export const DAIMON_BROKER_REALM = "/var/lib/spawnfile/daimon/grok-subscription-realm"; export const DAIMON_WORKER_ROOT = "/var/lib/daimon-workers"; -export const DAIMON_WORKER_ATTESTATION_ROOT = "/var/lib/daimon-worker-attestations"; +/** + * The organization runtime state directory. Owned by a non-worker host + * identity with mode 0755 (verified live: `drwxr-xr-x`, owner/group + * `spawnfile`) — deliberately world-traversable/listable, per + * `organizationReadyEvidence.ts`'s readiness receipt path underneath it and + * every other reader that needs it. That world-read bit is the one thing in + * `GROK_SANDBOX_DENY_PATHS` below a worker uid can actually open with no + * sandbox in effect (verified live with `setpriv --reuid ... open`), + * which is exactly why it stays denied: Grok's own bwrap mask over it is a + * real, verifiable effect, not a no-op layered on top of unix permissions + * that already refuse the worker. + */ +export const DAIMON_ORGANIZATION_STATE_DIRECTORY = path.posix.join( + DAIMON_RUNTIME_HOME_ROOT, + DAIMON_ORGANIZATION_TARGET_ID, + "state" +); +/** + * The Grok worker sandbox profile's `deny` list — see `profileFor` below. + * Every other candidate a worker might plausibly need masked from (peer + * worker homes/workspaces, the subscription realm, the bootstrap-auth file, + * the broker's own `/run` socket directory, the usage-ledger directory) is + * unix-denied to a worker uid unconditionally, by construction, elsewhere in + * this exact provisioning script or its sibling + * `renderDaimonUsageLedgerProvisioning`: each is force-chowned/chmoded (never + * merely checked) to a mode whose "other" class carries no read bit, and a + * worker uid never matches the owning uid/gid of any of them (workers run + * with `setresuid`/`setresgid` to their own dedicated uid==gid, cleared of + * every supplementary group — see `engineBrokerLauncherCore.inc`). Listing an + * already-unix-denied path in `deny` adds no protection — the kernel already + * refuses the worker — and it breaks Grok 1.0.13+, which opens every `deny` + * path at startup to confirm its own bwrap mount actually caused the denial; + * when the path was already unreadable for an unrelated reason, Grok cannot + * tell its mask from ambient permissions and refuses to start + * ("__GROK_INSIDE_BWRAP spoof" guard). Verified live against a running + * deployment (`clank-newsroom`, worker uid 2202) with + * `setpriv --reuid 2202 --regid 2202 --clear-groups`: every registrations.bin + * peer home/workspace, the realm, `grok-bootstrap-auth`, and + * `/run/daimon-engine-broker` were already unreadable; only the organization + * state directory below was actually openable. + */ +export const GROK_SANDBOX_DENY_PATHS = [DAIMON_ORGANIZATION_STATE_DIRECTORY]; interface WorkspaceSecurityResource { backingPath: string; @@ -35,7 +78,7 @@ export const renderDaimonWorkspaceResourceSecurity = ( `const workspaceResources = ${JSON.stringify(resources)};`, "const resourceByLink = new Map(workspaceResources.map((resource) => [resource.linkPath, resource])); if (resourceByLink.size !== workspaceResources.length) throw new Error('duplicate worker workspace resource link');", `const validateResourceLink = (target, info) => { const resource = resourceByLink.get(target); if (!resource || info.uid !== ${owners.linkUid} || info.gid !== ${owners.linkGid} || info.nlink !== 1) throw new Error('unsafe worker workspace link'); const raw = fs.readlinkSync(target), normalized = require('node:path').posix.normalize(raw); if (!raw.startsWith('/') || normalized !== raw || raw !== resource.backingPath || !raw.startsWith(${JSON.stringify(resourceRoot)})) throw new Error('unsafe worker workspace link target'); const backing = fs.lstatSync(raw); if (!backing.isDirectory() || backing.isSymbolicLink()) throw new Error('unsafe worker workspace resource'); const mode = backing.mode & 0o777; if (resource.kind === 'volume') { const lifecycleOwner = (backing.uid === ${owners.privilegedUid} && backing.gid === ${owners.privilegedGid}) || (backing.uid === ${owners.linkUid} && backing.gid === ${owners.linkGid}); if (!lifecycleOwner || mode !== 0o755 || typeof resource.resolvedIdentity !== 'string') throw new Error('unsafe worker workspace volume'); const expected = Buffer.from(\`${"${resource.resolvedIdentity}"}\\n\`); if (expected.length !== 72) throw new Error('unsafe worker workspace volume identity'); const sentinel = \`${"${raw}"}/.spawnfile-resource-identity\`; let fd; try { fd = fs.openSync(sentinel, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK); const before = fs.fstatSync(fd); if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || before.uid !== ${owners.privilegedUid} || before.gid !== ${owners.privilegedGid} || (before.mode & 0o777) !== 0o644 || before.size !== expected.length) throw new Error('unsafe worker workspace volume identity'); const bytes = fs.readFileSync(fd), after = fs.fstatSync(fd); if (!bytes.equals(expected) || after.dev !== before.dev || after.ino !== before.ino || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs) throw new Error('unsafe worker workspace volume identity'); } finally { expected.fill(0); if (fd !== undefined) fs.closeSync(fd); } } else if (resource.mode === 'readonly') { if (backing.uid !== ${owners.readonlyUid} || backing.gid !== ${owners.readonlyGid} || mode !== 0o555) throw new Error('unsafe readonly worker workspace resource'); } else if (backing.uid !== ${owners.privilegedUid} || backing.gid !== ${owners.privilegedGid} || mode !== 0o755) throw new Error('unsafe mutable worker workspace resource'); };`, - "const secureWorkspace = (root, uid) => { const visit = (target) => { const info = fs.lstatSync(target); if (info.isSymbolicLink()) { validateResourceLink(target, info); return; } if (info.isDirectory()) { fs.chownSync(target, 2000, uid); fs.chmodSync(target, 0o750); for (const name of fs.readdirSync(target)) visit(`${target}/${name}`); } else if (info.isFile()) { fs.chownSync(target, 2000, uid); fs.chmodSync(target, 0o640); } else throw new Error('unsafe worker workspace node'); }; visit(root); };" + "const secureWorkspace = (root, uid) => { const visit = (target) => { const info = fs.lstatSync(target); if (info.isSymbolicLink()) { validateResourceLink(target, info); return; } if (info.isDirectory()) { fs.chownSync(target, 0, 0); fs.chmodSync(target, 0o750); for (const name of fs.readdirSync(target)) visit(`${target}/${name}`); fs.chownSync(target, 2000, uid); } else if (info.isFile()) { fs.chownSync(target, 0, 0); fs.chmodSync(target, 0o640); fs.chownSync(target, 2000, uid); } else throw new Error('unsafe worker workspace node'); }; visit(root); };" ]; const nodeSlug = (nodeId: string): string => nodeId.replace(/^agent:/u, "") @@ -57,6 +100,40 @@ export const resolveDaimonGrokRegistrations = (plans: RuntimeTargetPlan[]) => pl uid: DAIMON_FIRST_WORKER_UID + slot })); +/** + * Fixes ownership and mode of the per-turn usage ledger directory for every + * Daimon organization, not just ones with a Grok agent. AGY and Codex both + * write here too (`onTurnUsage` in Daimon's `engineDispatcher.ts`), from the + * organization-uid runtime process (`uid`/`gid` below, set in + * `renderDaimonUidEntrypoint`) rather than the privileged broker — so this + * must run whether or not any Grok registration exists, and the directory + * must be group-writable (0770), not merely group-readable (0750): a mode + * that only lets the group list the directory silently defeated every + * AGY/Codex advisory usage write and, with it, Daimon's wake-fuse token + * ceiling, which now refuses to start at all if this ledger is missing or + * unreadable (`wakeFuse.ts`'s `ensureUsageLedgerReadable`). + * + * The directory itself is never created here: it is a persistent volume + * mount (`daimon-grok-usage-ledger` in `config.ts`, unconditional for every + * Daimon organization) that Docker always materializes before the entrypoint + * runs — exactly like `DAIMON_WAKE_FUSE_DIRECTORY`, whose own fix-up + * (`renderDaimonUidEntrypoint`) uses this same three-step order. `chown` to + * root first, `chmod` next, `chown` to the final owner last — never + * `install -d`'s create-then-chown-then-chmod order, and never a single + * `chown owner:group` followed by `chmod`: root only ever chmods a path it + * currently owns, so it never needs `CAP_FOWNER` (`runProject.ts`'s + * capability set grants `CAP_CHOWN` but not `CAP_FOWNER`) — chmod-ing + * *after* the final `chown` hands ownership to the broker uid fails with + * `EPERM` the moment root no longer owns the path. + */ +export const renderDaimonUsageLedgerProvisioning = (): string[] => { + const { directoryPath } = DAIMON_GROK_TURN_USAGE_LEDGER; + return [ + `chown 0:0 ${directoryPath} && chmod 0770 ${directoryPath} && chown ${DAIMON_BROKER_UID}:${DAIMON_ORGANIZATION_UID} ${directoryPath}`, + `setpriv --clear-groups --reuid ${DAIMON_ORGANIZATION_UID} --regid ${DAIMON_ORGANIZATION_UID} --inh-caps=-all --ambient-caps=-all --bounding-set=-all -- bash -ceu 'probe=${directoryPath}/.daimon-usage-probe; umask 007; : > "$probe"; rm "$probe"'` + ]; +}; + export const renderDaimonBrokerProvisioning = (plans: RuntimeTargetPlan[]): string[] => { const registrations = resolveDaimonGrokRegistrations(plans); if (registrations.length === 0) return []; @@ -80,30 +157,54 @@ export const renderDaimonBrokerProvisioning = (plans: RuntimeTargetPlan[]): stri "const digest = crypto.createHash('sha256').update(fs.readFileSync(executable)).digest();", "const cString = (buffer, offset, length, value) => { const bytes = Buffer.from(value); if (bytes.length < 1 || bytes.length >= length || bytes.includes(0)) throw new Error('invalid broker registration'); bytes.copy(buffer, offset); };", `const records = registrations.map((entry) => { const record = Buffer.alloc(692); record.writeUInt32LE(${DAIMON_GROK_ENGINE_BROKER.nativeAbiVersion}, 0); record.writeUInt32LE(entry.slot, 4); record.writeUInt32LE(entry.uid, 8); record.writeUInt32LE(entry.uid, 12); cString(record, 16, 129, entry.agentId); cString(record, 145, 256, entry.workspace); cString(record, 401, 256, entry.home); digest.copy(record, 657); return record; });`, - "fs.mkdirSync('/etc/daimon-engine-broker', { recursive: true, mode: 0o555 });", + "fs.mkdirSync('/etc/daimon-engine-broker', { recursive: true, mode: 0o700 }); fs.chownSync('/etc/daimon-engine-broker', 0, 0); fs.chmodSync('/etc/daimon-engine-broker', 0o700);", "fs.writeFileSync('/etc/daimon-engine-broker/registrations.bin', Buffer.concat(records), { mode: 0o400, flag: 'wx' });", "fs.chownSync('/etc/daimon-engine-broker/registrations.bin', 0, 0); fs.chmodSync('/etc/daimon-engine-broker/registrations.bin', 0o400);", - `fs.mkdirSync('${DAIMON_BROKER_REALM}', { recursive: true, mode: 0o700 }); fs.chownSync('${DAIMON_BROKER_REALM}', 2100, 2100); fs.chmodSync('${DAIMON_BROKER_REALM}', 0o700);`, - `fs.mkdirSync('${DAIMON_GROK_TURN_USAGE_LEDGER.directoryPath}', { recursive: true, mode: 0o750 }); fs.chownSync('${DAIMON_GROK_TURN_USAGE_LEDGER.directoryPath}', 2100, 2100); fs.chmodSync('${DAIMON_GROK_TURN_USAGE_LEDGER.directoryPath}', 0o750);`, + `fs.mkdirSync('${DAIMON_BROKER_REALM}', { recursive: true, mode: 0o700 }); fs.chownSync('${DAIMON_BROKER_REALM}', 0, 0); fs.chmodSync('${DAIMON_BROKER_REALM}', 0o700);`, + // The usage ledger directory itself is provisioned unconditionally by + // `renderDaimonUsageLedgerProvisioning` (every Daimon organization writes + // here, not just Grok ones) before this script's caller reaches the + // broker startup this function guards; this script only ever reads the + // path below, for the sandbox denylist. `const bootstrap = '/var/lib/spawnfile/daimon/grok-bootstrap-auth', authority = '${DAIMON_BROKER_REALM}/auth.json';`, "const readSecure = (file, owner, label) => { const before = fs.lstatSync(file); if (!before.isFile() || before.isSymbolicLink() || (owner !== undefined && (before.uid !== owner || before.gid !== owner)) || (before.mode & 0o777) !== 0o600 || before.nlink !== 1 || before.size < 2 || before.size > 65536) throw new Error(`unsafe broker credential ${label}`); const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK); try { const opened = fs.fstatSync(fd); if (opened.dev !== before.dev || opened.ino !== before.ino) throw new Error(`unsafe broker credential ${label}`); const bytes = Buffer.alloc(opened.size); let offset = 0; while (offset < bytes.length) { const count = fs.readSync(fd, bytes, offset, bytes.length - offset, offset); if (count < 1) throw new Error(`unsafe broker credential ${label}`); offset += count; } return bytes; } finally { fs.closeSync(fd); } };", - `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.fchownSync(output, 2100, 2100); fs.fchmodSync(output, 0o600); 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; } };`, + `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 config = '[auth_provider.daimon]\\ntype = \"custom\"\\ncommand = \"/opt/daimon/bin/daimon-engine-broker\"\\nargs = [\"--auth-provider\"]\\n\\n[model.daimon-broker-grok]\\nmodel = \"grok-build\"\\nbase_url = \"http://127.0.0.1:43123/v1\"\\nauth_provider = \"daimon\"\\ncontext_window = 131072\\nsupports_backend_search = false\\n\\n[mcp_servers.daimon]\\nurl = \"http://127.0.0.1:43124/mcp\"\\nheaders = { Authorization = \"Bearer ${DAIMON_MCP_CAPABILITY}\" }\\n';", - `for (const root of ['${DAIMON_WORKER_ROOT}','${DAIMON_WORKER_ATTESTATION_ROOT}']) { fs.mkdirSync(root, { recursive: true, mode: 0o711 }); fs.chownSync(root, 0, 0); fs.chmodSync(root, 0o711); }`, + `for (const root of ['${DAIMON_WORKER_ROOT}']) { fs.mkdirSync(root, { recursive: true, mode: 0o711 }); fs.chownSync(root, 0, 0); fs.chmodSync(root, 0o711); }`, ...renderDaimonWorkspaceResourceSecurity(workspaceResources), - `const deniedFor = (entry) => registrations.filter((peer) => peer.uid !== entry.uid).flatMap((peer) => [peer.home, peer.workspace]).concat(['${DAIMON_BROKER_REALM}', '/var/lib/spawnfile/daimon/grok-bootstrap-auth', '/run/daimon-engine-broker', '/var/lib/spawnfile/instances/daimon/daimon-organization/state', '${DAIMON_GROK_TURN_USAGE_LEDGER.directoryPath}']);`, - "const profileFor = (entry) => `[profiles.daimon-strict]\\nextends = \"strict\"\\nrestrict_network = true\\ndeny = [${deniedFor(entry).map(JSON.stringify).join(', ')}]\\n`;", - "const ensureDirectory = (target, uid, gid, mode) => { fs.mkdirSync(target, { recursive: true, mode }); const info = fs.lstatSync(target); if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('unsafe worker runtime directory'); fs.chownSync(target, uid, gid); fs.chmodSync(target, mode); };", - "const ensureExactFile = (target, content, uid, gid, 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'); const existing = fs.readFileSync(target, 'utf8'); if (existing !== content) throw new Error('worker runtime file identity mismatch'); fs.chownSync(target, uid, gid); 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) || (info.gid !== 2100 && info.gid !== 0) || ![0o600,0o640].includes(info.mode & 0o777)) throw new Error('unsafe worker attestation events'); fs.chownSync(target, uid, 2100); fs.chmodSync(target, 0o640); };", - "const ensureExactLink = (target, source) => { let info; try { info = fs.lstatSync(target); } catch (error) { if (error.code !== 'ENOENT') throw error; fs.symlinkSync(source, target); info = fs.lstatSync(target); } if (!info.isSymbolicLink() || info.nlink !== 1 || fs.readlinkSync(target) !== source) throw new Error('worker runtime link identity mismatch'); };", - `for (const entry of registrations) { secureWorkspace(entry.workspace, entry.uid); ensureDirectory(entry.home, entry.uid, entry.uid, 0o700); const configRoot = \`${"${entry.home}"}/.grok\`; ensureDirectory(configRoot, 0, 0, 0o555); const configPath = \`${"${configRoot}"}/config.toml\`; ensureExactFile(configPath, config, 0, 0, 0o444); const attestationRoot = \`${DAIMON_WORKER_ATTESTATION_ROOT}/\${entry.uid}\`; ensureDirectory(attestationRoot, 0, 0, 0o755); const profilePath = \`${"${attestationRoot}"}/sandbox.toml\`, eventsPath = \`${"${attestationRoot}"}/sandbox-events.jsonl\`; ensureExactFile(profilePath, profileFor(entry), 0, 0, 0o444); ensureEventsFile(eventsPath, entry.uid); ensureExactLink(\`${"${configRoot}"}/sandbox.toml\`, profilePath); ensureExactLink(\`${"${configRoot}"}/sandbox-events.jsonl\`, eventsPath); }`, - `const service = { version: 'noopolis.daimon.engine-broker-service.v1', credentialHome: '/var/lib/spawnfile/daimon/grok-subscription-realm', turnStore: '/var/lib/spawnfile/daimon/grok-subscription-realm/turns', registrations: registrations.map((entry) => { const attestationRoot = \`${DAIMON_WORKER_ATTESTATION_ROOT}/\${entry.uid}\`, profilePath = \`${"${attestationRoot}"}/sandbox.toml\`, eventsPath = \`${"${attestationRoot}"}/sandbox-events.jsonl\`; return { agentId: entry.agentId, slot: entry.slot, workerUid: entry.uid, workspace: entry.workspace, profilePath, eventsPath, profileSha256: crypto.createHash('sha256').update(profileFor(entry)).digest('hex') }; }) };`, - "fs.writeFileSync('/etc/daimon-engine-broker/service.json', `${JSON.stringify(service)}\n`, { mode: 0o440, flag: 'wx' }); fs.chownSync('/etc/daimon-engine-broker/service.json', 0, 2100); fs.chmodSync('/etc/daimon-engine-broker/service.json', 0o440);" + // Every candidate the worker might plausibly need masked from other than + // the organization state directory (peer worker homes/workspaces, the + // subscription realm, grok-bootstrap-auth, /run/daimon-engine-broker, the + // usage ledger) is already unix-denied to a worker uid unconditionally by + // this exact script and its `renderDaimonUsageLedgerProvisioning` + // sibling, which force-chown/chmod each one to a mode whose "other" class + // carries no read bit for uids that are never the owner or group. Listing + // an already-unix-denied path adds no protection and breaks Grok + // 1.0.13+, which opens every `deny` path at startup to confirm its own + // bwrap mount actually caused the denial and refuses to start + // ("__GROK_INSIDE_BWRAP spoof" guard) when it can't tell its mask from + // ambient permissions. See `GROK_SANDBOX_DENY_PATHS`'s doc comment for + // the live verification this rests on. + `const deniedPaths = ${JSON.stringify(GROK_SANDBOX_DENY_PATHS)};`, + "const profileFor = () => `[profiles.daimon-strict]\\nextends = \"strict\"\\nrestrict_network = true\\ndeny = [${deniedPaths.map(JSON.stringify).join(', ')}]\\n`;", + "const ensureDirectory = (target, uid, gid, mode) => { fs.mkdirSync(target, { recursive: true, mode }); 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, uid, gid, 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'); const existing = fs.readFileSync(target, 'utf8'); if (existing !== content) throw new Error('worker runtime file identity mismatch'); fs.chownSync(target, 0, 0); fs.chmodSync(target, mode); fs.chownSync(target, uid, gid); };", + "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) || (info.gid !== 2100 && info.gid !== 0) || ![0o600,0o640].includes(info.mode & 0o777)) throw new Error('unsafe worker attestation events'); fs.chownSync(target, 0, 0); fs.chmodSync(target, 0o640); fs.chownSync(target, uid, 2100); };", + "// Grok refuses a sandbox profile reached through a symlink ('retargetable'), so the", + "// worker's view is a hard link: one inode, identical bytes, no retargetable component.", + `for (const entry of registrations) { for (let ancestor = require('node:path').dirname(entry.workspace); ancestor.startsWith('/var/lib/spawnfile/') && ancestor.length > '/var/lib/spawnfile'.length; ancestor = require('node:path').dirname(ancestor)) fs.chmodSync(ancestor, fs.statSync(ancestor).mode & 0o7777 | 0o011); secureWorkspace(entry.workspace, entry.uid); ensureDirectory(entry.home, 0, 0, 0o700); const configRoot = \`${"${entry.home}"}/.grok\`; ensureDirectory(configRoot, 0, 0, 0o700); const configPath = \`${"${configRoot}"}/config.toml\`; ensureExactFile(configPath, config, 0, 0, 0o444); const profilePath = \`${"${configRoot}"}/sandbox.toml\`, eventsPath = \`${"${configRoot}"}/sandbox-events.jsonl\`; ensureExactFile(profilePath, profileFor(), 0, 0, 0o444); ensureEventsFile(eventsPath, entry.uid); ensureDirectory(configRoot, 0, entry.uid, 0o1771); ensureDirectory(entry.home, entry.uid, ${DAIMON_BROKER_UID}, 0o710); }`, + `const service = { version: 'noopolis.daimon.engine-broker-service.v1', credentialHome: '/var/lib/spawnfile/daimon/grok-subscription-realm', turnStore: '/var/lib/spawnfile/daimon/grok-subscription-realm/turns', registrations: registrations.map((entry) => { const configRoot = \`${"${entry.home}"}/.grok\`, profilePath = \`${"${configRoot}"}/sandbox.toml\`, eventsPath = \`${"${configRoot}"}/sandbox-events.jsonl\`; return { agentId: entry.agentId, slot: entry.slot, workerUid: entry.uid, workspace: entry.workspace, profilePath, eventsPath, profileSha256: crypto.createHash('sha256').update(profileFor()).digest('hex') }; }) };`, + "fs.writeFileSync('/etc/daimon-engine-broker/service.json', `${JSON.stringify(service)}\n`, { mode: 0o440, flag: 'wx' }); fs.chownSync('/etc/daimon-engine-broker/service.json', 0, 2100); fs.chmodSync('/etc/daimon-engine-broker/service.json', 0o440);", + `fs.chownSync('${DAIMON_BROKER_REALM}', 0, 0); fs.chmodSync('${DAIMON_BROKER_REALM}', 0o700); fs.chownSync('${DAIMON_BROKER_REALM}', 2100, 2100);`, + "fs.chmodSync('/etc/daimon-engine-broker', 0o555);" ].join("\n"); return [ + "if [ -d /etc/daimon-engine-broker ]; then chmod u+rwx /etc/daimon-engine-broker; fi", + "if [ -d /run/daimon-engine-broker ]; then chmod u+rwx /run/daimon-engine-broker; fi", "rm -rf /etc/daimon-engine-broker /run/daimon-engine-broker", `install -d -o root -g ${DAIMON_BROKER_UID} -m 0731 /run/daimon-engine-broker`, "node <<'SPAWNFILE_DAIMON_BROKER_PROVISION'", diff --git a/src/compiler/containerDaimonCapabilityOrdering.test.ts b/src/compiler/containerDaimonCapabilityOrdering.test.ts new file mode 100644 index 00000000..ce35c95c --- /dev/null +++ b/src/compiler/containerDaimonCapabilityOrdering.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import type { RuntimeTargetPlan } from "./containerArtifactsTypes.js"; +import { renderDaimonUidEntrypoint } from "./containerDaimonUidEntrypointRender.js"; +import { createStateOwnershipCommand } from "./containerStateOwnershipRender.js"; + +const daimonPlan = { + engineByNodeId: { "agent:grok": "grok" }, + instancePaths: { + configPath: "/var/lib/spawnfile/instances/daimon/organization/daimon/config.json", + instanceRoot: "/var/lib/spawnfile/instances/daimon/organization", + workspacePath: "/var/lib/spawnfile/instances/daimon/organization/workspace" + }, + runtimeName: "daimon", + runtimeRoot: "/opt/daimon" +} as unknown as RuntimeTargetPlan; + +describe("Daimon shell capability-safe ordering", () => { + it("reclaims, modes, and restores existing broker auth ownership", () => { + const rendered = renderDaimonUidEntrypoint([daimonPlan]); + expect(rendered).toContain( + "chown 0:0 '/var/lib/spawnfile/daimon/grok-subscription-realm/auth.json'; chmod 0600 '/var/lib/spawnfile/daimon/grok-subscription-realm/auth.json'; chown 2100:2100 '/var/lib/spawnfile/daimon/grok-subscription-realm/auth.json'" + ); + }); + + it("reclaims, modes, and restores Moltnet config ownership", () => { + const configPath = "/var/lib/spawnfile/moltnet/nodes/agent.json"; + const rendered = createStateOwnershipCommand([daimonPlan], [], { + nodePlans: [{ configPath, networkId: "local" }], + serverPlans: [] + }); + expect(rendered).toContain( + `chown root:root '${configPath}' && chmod 600 '${configPath}' && chown 2000:2000 '${configPath}'` + ); + }); +}); diff --git a/src/compiler/containerDaimonOwnershipGuardRender.ts b/src/compiler/containerDaimonOwnershipGuardRender.ts index 557d6964..52a7993a 100644 --- a/src/compiler/containerDaimonOwnershipGuardRender.ts +++ b/src/compiler/containerDaimonOwnershipGuardRender.ts @@ -161,6 +161,18 @@ export const renderDaimonOwnershipProgram = ( "const secureVolumeIdentity = (entry) => { const parentPath = require('node:path').posix.dirname(entry.path); if (entry.path !== `${parentPath}/.spawnfile-resource-identity`) fail('volume identity anchor path is invalid'); const parent = openDirectoryPath(parentPath); let marker, sentinel; try { const parentInfo = fs.fstatSync(parent), parentMode = parentInfo.mode & 0o777, freshParent = parentInfo.uid === 0 && parentInfo.gid === 0 && parentMode === 0o755, establishedParent = parentInfo.uid === uid && parentInfo.gid === uid && parentMode === 0o755; if (!freshParent && !establishedParent) fail('volume identity parent is unsafe'); const names = fs.readdirSync(`/proc/self/fd/${parent}`).sort(), hasSentinel = names.includes('.spawnfile-resource-identity'), hasMarker = names.includes(volumeBootstrapMarker); if (hasSentinel) { sentinel = fs.openSync(`/proc/self/fd/${parent}/.spawnfile-resource-identity`, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); verifyVolumeIdentityFile(sentinel, `${entry.identity}\n`, 0o644, parentInfo.dev); if (hasMarker) { if (!freshParent || names.length !== 2) fail('volume bootstrap recovery preimage is unsafe'); marker = fs.openSync(`/proc/self/fd/${parent}/${volumeBootstrapMarker}`, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); verifyVolumeIdentityFile(marker, volumeBootstrapContent, 0o600, parentInfo.dev); fs.fsyncSync(sentinel); fs.fsyncSync(parent); verifyVolumeIdentityFile(sentinel, `${entry.identity}\n`, 0o644, parentInfo.dev); verifyVolumeIdentityFile(marker, volumeBootstrapContent, 0o600, parentInfo.dev); fs.unlinkSync(`/proc/self/fd/${parent}/${volumeBootstrapMarker}`); fs.fsyncSync(parent); } return; } if (!freshParent || names.length !== 1 || !hasMarker) fail('volume bootstrap preimage is unsafe'); marker = fs.openSync(`/proc/self/fd/${parent}/${volumeBootstrapMarker}`, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); verifyVolumeIdentityFile(marker, volumeBootstrapContent, 0o600, parentInfo.dev); const expected = Buffer.from(`${entry.identity}\n`); try { sentinel = fs.openSync(`/proc/self/fd/${parent}/.spawnfile-resource-identity`, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o644); let offset=0; while(offset{ const volumeName = `${tag}-realm-volume`; const runtimeHomeVolumeName = `${tag}-agy-runtime-home-volume`; const codexVolumeName = `${tag}-codex-engine-home-volume`; + const wakeFuseVolumeName = `${tag}-wake-fuse-volume`; + const usageLedgerVolumeName = `${tag}-usage-ledger-volume`; const networkVolumeName = `${tag}-moltnet-network-volume`; const resourceVolumeName=`${tag}-workspace-resource-volume`; const networkRoot = "/var/lib/spawnfile/moltnet/networks/local"; @@ -141,7 +146,21 @@ describe("renderDaimonUidEntrypoint lifecycle",()=>{ persistentMounts: [ { ...agyRealmMount, volume_name: volumeName }, { ...agyRuntimeHomeMount, volume_name: runtimeHomeVolumeName }, - { ...codexEngineHomeMount, volume_name: codexVolumeName } + { ...codexEngineHomeMount, volume_name: codexVolumeName }, + { + id: "daimon-wake-fuse", + lifecycle: "exclusive-reattach", + mount_path: wakeFuseDirectory, + reason: "Daimon durable wake-fuse admission ledger", + volume_name: wakeFuseVolumeName + }, + { + id: "daimon-grok-usage-ledger", + lifecycle: "exclusive-reattach", + mount_path: usageLedgerDirectory, + reason: "Daimon per-turn engine usage ledger", + volume_name: usageLedgerVolumeName + } ], resources: [{ backingPath: "/var/lib/spawnfile/resources/instances/writer/public", @@ -172,7 +191,7 @@ describe("renderDaimonUidEntrypoint lifecycle",()=>{ const { createRootfsFiles, renderDockerfile } = await import("./containerArtifactsRender.js"); const dockerfile = await renderDockerfile([plan], { moltnet: receiptMoltnetPlans, - persistentMountPaths: [agyRealm, agyRuntimeHome, codexEngineHome, causalState, networkRoot,volumeResourceRoot] + persistentMountPaths: [agyRealm, agyRuntimeHome, codexEngineHome, wakeFuseDirectory, usageLedgerDirectory, causalState, networkRoot,volumeResourceRoot] }); const stateRoots = resolveDaimonUidEntrypointStateRoots([plan]); expect(stateRoots).toEqual([runtimeHomesPath, workspacePath]); @@ -187,7 +206,7 @@ describe("renderDaimonUidEntrypoint lifecycle",()=>{ expect(dockerfile).toContain(`'${receiptDirectory}'`); const rootfsFiles = createRootfsFiles( [plan], - [agyRealm, agyRuntimeHome, codexEngineHome, causalState, networkRoot, volumeResourceRoot], + [agyRealm, agyRuntimeHome, codexEngineHome, wakeFuseDirectory, usageLedgerDirectory, causalState, networkRoot, volumeResourceRoot], receiptMoltnetPlans ); expect(rootfsFiles.find((file) => file.path.endsWith("daimon-uid-entrypoint.sh"))?.content) @@ -264,6 +283,8 @@ describe("renderDaimonUidEntrypoint lifecycle",()=>{ `test \"$(stat -c '%u:%a' '${receiptDirectory}')\" = \"\${${DAIMON_AUTHORIZED_UID_ENV}}:700\"`, `test \"$(stat -c '%u:%a' '/run/spawnfile/moltnet-readiness')\" = \"\${${DAIMON_AUTHORIZED_UID_ENV}}:700\"`, `test \"$(stat -c '%u:%a' '${agyRealm}')\" = \"\${${DAIMON_AUTHORIZED_UID_ENV}}:700\"`, + `test \"$(stat -c '%u:%g:%a' '${wakeFuseDirectory}')\" = \"\${${DAIMON_AUTHORIZED_UID_ENV}}:\${${DAIMON_AUTHORIZED_UID_ENV}}:700\"`, + `test \"$(stat -c '%u:%g:%a' '${usageLedgerDirectory}')\" = \"2100:\${${DAIMON_AUTHORIZED_UID_ENV}}:770\"`, `test \"$(stat -c '%u:%a' '${runtimeHomesPath}')\" = \"\${${DAIMON_AUTHORIZED_UID_ENV}}:700\"`, `test \"$(stat -c '%u:%a' '${workspacePath}')\" = \"\${${DAIMON_AUTHORIZED_UID_ENV}}:700\"`, `if [ ! -e '${volumeResourceRoot}/content' ]; then printf content > '${volumeResourceRoot}/content'; fi`, @@ -322,6 +343,8 @@ describe("renderDaimonUidEntrypoint lifecycle",()=>{ "--mount", `type=volume,source=${volumeName},target=${agyRealm}`, "--mount", `type=volume,source=${runtimeHomeVolumeName},target=${agyRuntimeHome}`, "--mount", `type=volume,source=${codexVolumeName},target=${codexEngineHome}`, + "--mount", `type=volume,source=${wakeFuseVolumeName},target=${wakeFuseDirectory}`, + "--mount", `type=volume,source=${usageLedgerVolumeName},target=${usageLedgerDirectory}`, "--mount", `type=volume,source=${networkVolumeName},target=${networkRoot}`, "--mount",`type=volume,source=${resourceVolumeName},target=${volumeResourceRoot},volume-nocopy`, tag @@ -357,6 +380,8 @@ describe("renderDaimonUidEntrypoint lifecycle",()=>{ await execFile("docker", ["volume", "rm", "--force", volumeName]).catch(() => undefined); await execFile("docker", ["volume", "rm", "--force", runtimeHomeVolumeName]).catch(() => undefined); await execFile("docker", ["volume", "rm", "--force", codexVolumeName]).catch(() => undefined); + await execFile("docker", ["volume", "rm", "--force", wakeFuseVolumeName]).catch(() => undefined); + await execFile("docker", ["volume", "rm", "--force", usageLedgerVolumeName]).catch(() => undefined); await execFile("docker", ["volume", "rm", "--force", networkVolumeName]).catch(() => undefined); await execFile("docker",["volume","rm","--force",resourceVolumeName]).catch(()=>undefined); await execFile("docker", ["image", "rm", "--force", tag]).catch(() => undefined); diff --git a/src/compiler/containerDaimonUidEntrypointRender.test.ts b/src/compiler/containerDaimonUidEntrypointRender.test.ts index 88f8daa5..f9679baf 100644 --- a/src/compiler/containerDaimonUidEntrypointRender.test.ts +++ b/src/compiler/containerDaimonUidEntrypointRender.test.ts @@ -9,6 +9,7 @@ import type { RuntimeTargetPlan } from "./containerArtifactsTypes.js"; import type { EntrypointOptions } from "./containerEntrypointRender.js"; import { renderEntrypoint } from "./containerEntrypointRender.js"; import { DAIMON_GROK_TURN_USAGE_LEDGER } from "../runtime/daimon/contractManifest.js"; +import { DAIMON_WAKE_FUSE_DIRECTORY } from "../runtime/daimon/config.js"; import { DAIMON_AUTHORIZED_UID_ENV, DAIMON_BROKER_STARTUP_TIMEOUT_SECONDS, @@ -177,8 +178,11 @@ describe("renderDaimonUidEntrypoint", () => { expect(rendered).toContain("http://127.0.0.1:43124/mcp"); expect(rendered).toContain("DAIMON_MCP_CAPABILITY"); expect(rendered).toContain("/var/lib/daimon-workers/2200"); - expect(rendered).toContain("/var/lib/daimon-worker-attestations/"); - expect(rendered).toContain("ensureExactLink(`${configRoot}/sandbox.toml`, profilePath)"); + expect(rendered).toContain("/var/lib/daimon-workers/"); + // Grok refuses a profile that is a symlink or carries a hard-link alias, so it is an + // unaliased file in the worker's own read-only .grok directory. + expect(rendered).toContain("ensureExactFile(profilePath, profileFor(), 0, 0, 0o444)"); + expect(rendered).not.toContain("ensureExactLink"); expect(rendered).toContain("sandbox-events.jsonl"); expect(rendered).toContain("restrict_network = true"); expect(rendered).toContain("fs.chmodSync(target, 0o750)"); @@ -194,7 +198,7 @@ describe("renderDaimonUidEntrypoint", () => { const recovery=rendered.indexOf("if (hasMarker)");const durableSentinel=rendered.indexOf("fs.fsyncSync(sentinel)",recovery),durableParent=rendered.indexOf("fs.fsyncSync(parent)",durableSentinel),removeMarker=rendered.indexOf("fs.unlinkSync",durableParent),durableRemoval=rendered.indexOf("fs.fsyncSync(parent)",removeMarker);expect(recovery).toBeGreaterThan(-1);expect(durableSentinel).toBeGreaterThan(recovery);expect(durableParent).toBeGreaterThan(durableSentinel);expect(removeMarker).toBeGreaterThan(durableParent);expect(durableRemoval).toBeGreaterThan(removeMarker); expect(rendered).toContain("validateResourceLink(target, info); return;"); expect(rendered).toContain("worker runtime file identity mismatch"); - expect(rendered).toContain("worker runtime link identity mismatch"); + expect(rendered).toContain("worker runtime file identity mismatch"); expect(rendered).toContain("ensureEventsFile(eventsPath, entry.uid)"); expect(rendered).toContain("noopolis.daimon.engine-broker-service.v1"); expect(rendered).toContain("/etc/daimon-engine-broker/service.json"); @@ -205,7 +209,7 @@ describe("renderDaimonUidEntrypoint", () => { expect(rendered).toContain("generation: journal.generation + 1"); expect(rendered).toContain("state: 'promoted'"); expect(rendered).toContain("bootstrapBytes.fill(0)"); - expect(rendered).toContain("ensureExactFile(profilePath, profileFor(entry), 0, 0, 0o444)"); + expect(rendered).toContain("ensureExactFile(profilePath, profileFor(), 0, 0, 0o444)"); expect(rendered).toContain("--bounding-set=-all,+chown,+setuid,+setgid -- '/opt/daimon/bin/daimon-engine-broker' &"); expect(rendered).toContain("--bounding-set=-all,+chown,+setuid,+setgid,+setpcap -- '/opt/daimon/bin/daimon-engine-broker' --relay &"); expect(rendered).toContain('"$relay_pid:2100:0000000000000000"'); @@ -300,8 +304,12 @@ describe("renderDaimonUidEntrypoint", () => { { anchor: "/run", target: "/run/spawnfile/moltnet-readiness" } ], opaqueDescendantRoots: [codexEngineHome, grokEngineHome], + // "/var/lib/spawnfile/daimon" is deliberately absent: it hosts several + // independently-owned children (this AGY realm at the organization uid, + // the usage ledger at broker:organization) and must stay a shared, + // root-owned traversal ancestor (secureFixedTraversalAncestor), never + // chowned to the organization uid via this generic per-mount walk. privateDirectories: [ - "/var/lib/spawnfile/daimon", agyRealm, "/var/lib/spawnfile/instances", "/var/lib/spawnfile/instances/daimon", @@ -388,13 +396,64 @@ describe("renderDaimonUidEntrypoint", () => { expect(rendered).not.toContain(`state_roots=('${usageDirectory}')`); }); - it("provisions the usage ledger directory and probes it for write access on every boot", () => { + it("fixes the usage ledger directory group-writable and probes it for write access on every boot, even with no Grok agent", () => { const usageDirectory = DAIMON_GROK_TURN_USAGE_LEDGER.directoryPath; + // AGY and Codex write their advisory per-turn usage from the organization-uid + // (2000) runtime process, not the (Grok-only) broker (2100), so this must be + // unconditional and the directory must be group-*writable* (0770). A mode + // that only grants the group read+execute lets the organization uid list the + // directory but not create `usage.jsonl` inside it — silently defeating every + // AGY/Codex advisory usage write, and with it the wake fuse's token ceiling, + // which now refuses to start at all if this ledger is missing or unreadable. + // chown-to-root, then chmod, then chown-to-final-owner last (never + // `install -d`'s create-then-chown-then-chmod order): the directory is a + // persistent volume mount Docker always materializes before the entrypoint + // runs, not something this line creates, and chmod-ing after handing + // ownership to the broker uid would need CAP_FOWNER, which is not granted + // (`runProject.ts`). + const codexOnlyPlan: RuntimeTargetPlan = { ...daimonPlan, engineByNodeId: { "agent:Codex One": "codex" } }; + const rendered = renderDaimonUidEntrypoint([codexOnlyPlan]); + + expect(rendered).toContain(`chown 0:0 ${usageDirectory} && chmod 0770 ${usageDirectory} && chown 2100:2000 ${usageDirectory}`); + expect(rendered).toContain( + `--reuid 2000 --regid 2000 --inh-caps=-all --ambient-caps=-all --bounding-set=-all -- bash -ceu 'probe=${usageDirectory}/.daimon-usage-probe; umask 007; : > "$probe"; rm "$probe"'` + ); + }); + + it("keeps /var/lib/spawnfile/daimon a shared root-owned traversal ancestor, never chowned to the organization uid", () => { + // Regression coverage for the real failure this shape reproduced: an + // organization with an AGY agent (whose realm mount lives under + // /var/lib/spawnfile/daimon) got that shared ancestor chowned to the + // organization uid as a side effect of securing the realm mount, which + // then made the unconditional usage-ledger fix-up above fail — root has + // no CAP_DAC_OVERRIDE (runProject.ts) and no longer owns the directory. + const agyPlan: RuntimeTargetPlan = { ...daimonPlan, engineByNodeId: { "agent:AGY": "agy" }, persistentMounts: [agyRealmMount] }; + const rendered = renderDaimonUidEntrypoint([agyPlan]); + + expect(rendered).toContain("secureFixedTraversalAncestor('/var/lib/spawnfile/daimon');"); + const ownership = resolveDaimonUidEntrypointOwnershipPlan([agyPlan], [agyRealm]); + expect(ownership.privateDirectories).not.toContain("/var/lib/spawnfile/daimon"); + expect(ownership.privateDirectories).toContain(agyRealm); + }); + + it("provisions the wake-fuse directory for the organization identity", () => { + const ownership = resolveDaimonUidEntrypointOwnershipPlan( + [{ ...daimonPlan, persistentMounts: [{ + id: "daimon-wake-fuse", + mount_path: DAIMON_WAKE_FUSE_DIRECTORY, + reason: "Daimon durable wake-fuse admission ledger", + volume_name: "spawnfile-test-wake-fuse" + }] }], + [DAIMON_WAKE_FUSE_DIRECTORY] + ); + expect(ownership.stateRoots).not.toContain(DAIMON_WAKE_FUSE_DIRECTORY); + const rendered = renderDaimonUidEntrypoint([daimonPlan]); - expect(rendered).toContain(`install -d -o 2100 -g 2100 -m 0750 '${usageDirectory}'`); + // Mutation-critical: changing either owner identity or the private mode + // must turn this assertion red. expect(rendered).toContain( - `--reuid 2100 --regid 2100 --inh-caps=-all --ambient-caps=-all --bounding-set=-all -- bash -ceu 'probe=${usageDirectory}/.daimon-usage-probe; umask 027; : > "$probe"; rm "$probe"'` + `chown 0:0 '${DAIMON_WAKE_FUSE_DIRECTORY}' && chmod 0700 '${DAIMON_WAKE_FUSE_DIRECTORY}' && chown 2000:2000 '${DAIMON_WAKE_FUSE_DIRECTORY}'` ); }); diff --git a/src/compiler/containerDaimonUidEntrypointRender.ts b/src/compiler/containerDaimonUidEntrypointRender.ts index 0727ce55..5d54ec72 100644 --- a/src/compiler/containerDaimonUidEntrypointRender.ts +++ b/src/compiler/containerDaimonUidEntrypointRender.ts @@ -2,7 +2,10 @@ import path from "node:path"; import type { RuntimeTargetPlan } from "./containerArtifactsTypes.js"; import type { EntrypointOptions } from "./containerEntrypointRender.js"; -import { DAIMON_RUNTIME_ACCEPTANCE_STORE_MOUNT_ID } from "../runtime/daimon/config.js"; +import { + DAIMON_RUNTIME_ACCEPTANCE_STORE_MOUNT_ID, + DAIMON_WAKE_FUSE_DIRECTORY +} from "../runtime/daimon/config.js"; import { DAIMON_GROK_TURN_USAGE_LEDGER } from "../runtime/daimon/contractManifest.js"; import { MOLTNET_READINESS_DIRECTORY } from "./containerReadinessPaths.js"; import { @@ -23,6 +26,7 @@ import { DAIMON_BROKER_UID, DAIMON_ORGANIZATION_UID, renderDaimonBrokerProvisioning, + renderDaimonUsageLedgerProvisioning, resolveDaimonGrokRegistrations } from "./containerDaimonBrokerRender.js"; @@ -63,6 +67,24 @@ export const renderDaimonBrokerSocketWait = ( ]; const SPAWNFILE_PRIVATE_STATE_ROOT = "/var/lib/spawnfile"; +/** + * `/var/lib/spawnfile/daimon` hosts several independently-owned children — + * the AGY/Grok subscription realms (organization uid), the broker realm and + * usage ledger (broker uid, or broker:organization), the wake fuse + * (organization uid) — so it must stay a shared, root-owned, universally + * traversable ancestor (0711), exactly like `SPAWNFILE_PRIVATE_STATE_ROOT` + * itself, never chowned to the organization uid. Before this exclusion, any + * organization with an AGY agent (whose realm mount lives here and is not + * itself excluded from the ancestor walk below) got this directory chowned + * to the organization uid as a side effect of securing that one mount — + * which then made every *other* child that must be provisioned here as a + * different uid (most concretely, the usage ledger provisioned unconditionally + * by `renderDaimonUsageLedgerProvisioning`) fail to be created at all, because + * the entrypoint runs that provisioning as root without `CAP_DAC_OVERRIDE` + * (`runProject.ts`'s capability set), which cannot write into a directory it + * does not own by matching uid or gid. + */ +const DAIMON_SHARED_STATE_ROOT = `${SPAWNFILE_PRIVATE_STATE_ROOT}/daimon`; const DAIMON_AGY_SUBSCRIPTION_REALM_MOUNT_ID = "daimon-agy-subscription-realm"; const DAIMON_AGY_RUNTIME_HOME_MOUNT_ID_PREFIX = "daimon-agy-runtime-home-"; const DAIMON_PORTABLE_ENGINE_HOME_MOUNT_ID_PREFIX = "daimon-engine-home-"; @@ -115,7 +137,8 @@ const writableStateRoots = ( ]) ].filter((root) => root.startsWith("/") && root !== DAIMON_BROKER_REALM - && root !== DAIMON_GROK_TURN_USAGE_LEDGER.directoryPath).sort(); + && root !== DAIMON_GROK_TURN_USAGE_LEDGER.directoryPath + && root !== DAIMON_WAKE_FUSE_DIRECTORY).sort(); const privateDirectoriesThrough = (target: string): string[] => { if ( @@ -223,7 +246,7 @@ export const resolveDaimonUidEntrypointOwnershipPlan = ( privateDirectoriesThrough(path.posix.dirname(configPath)) ) ]) - ].filter((directory) => directory !== SPAWNFILE_PRIVATE_STATE_ROOT).sort(); + ].filter((directory) => directory !== SPAWNFILE_PRIVATE_STATE_ROOT && directory !== DAIMON_SHARED_STATE_ROOT).sort(); return { creatablePrivateDirectories: creatableTargets.map((target) => ({ anchor: target === MOLTNET_READINESS_DIRECTORY @@ -303,6 +326,11 @@ export const renderDaimonUidEntrypoint = ( ' useradd -K UID_MIN=1 --no-create-home --no-log-init --uid "$uid" --gid "$gid" --home-dir /nonexistent --shell /usr/sbin/nologin "$runtime_identity"', 'fi', 'if ! getent passwd "$uid" >/dev/null; then echo "Daimon authorized UID has no local identity" >&2; exit 1; fi', + `chown 0:0 ${quote(DAIMON_WAKE_FUSE_DIRECTORY)} && chmod 0700 ${quote(DAIMON_WAKE_FUSE_DIRECTORY)} && chown ${DAIMON_ORGANIZATION_UID}:${DAIMON_ORGANIZATION_UID} ${quote(DAIMON_WAKE_FUSE_DIRECTORY)}`, + // Unconditional: AGY and Codex write the per-turn usage ledger from the + // organization-uid runtime process below, not the (Grok-only) broker, so + // this cannot be gated on `resolveDaimonGrokRegistrations`. + ...renderDaimonUsageLedgerProvisioning(), ...(resolveDaimonGrokRegistrations(runtimePlans).length === 0 ? [] : [ ...renderDaimonBrokerSocketWait(), "startup_children=()", @@ -310,10 +338,8 @@ export const renderDaimonUidEntrypoint = ( "trap cleanup_broker_startup EXIT", "trap 'exit 143' TERM INT HUP", `install -d -o ${DAIMON_BROKER_UID} -g ${DAIMON_BROKER_UID} -m 0700 ${quote(DAIMON_BROKER_REALM)}`, - `if [ -e ${quote(`${DAIMON_BROKER_REALM}/auth.json`)} ]; then test -f ${quote(`${DAIMON_BROKER_REALM}/auth.json`)} && test ! -L ${quote(`${DAIMON_BROKER_REALM}/auth.json`)}; chown ${DAIMON_BROKER_UID}:${DAIMON_BROKER_UID} ${quote(`${DAIMON_BROKER_REALM}/auth.json`)}; chmod 0600 ${quote(`${DAIMON_BROKER_REALM}/auth.json`)}; fi`, + `if [ -e ${quote(`${DAIMON_BROKER_REALM}/auth.json`)} ]; then test -f ${quote(`${DAIMON_BROKER_REALM}/auth.json`)} && test ! -L ${quote(`${DAIMON_BROKER_REALM}/auth.json`)}; chown 0:0 ${quote(`${DAIMON_BROKER_REALM}/auth.json`)}; chmod 0600 ${quote(`${DAIMON_BROKER_REALM}/auth.json`)}; chown ${DAIMON_BROKER_UID}:${DAIMON_BROKER_UID} ${quote(`${DAIMON_BROKER_REALM}/auth.json`)}; fi`, `setpriv --clear-groups --reuid ${DAIMON_BROKER_UID} --regid ${DAIMON_BROKER_UID} --inh-caps=-all --ambient-caps=-all --bounding-set=-all -- bash -ceu 'probe=${DAIMON_BROKER_REALM}/.daimon-ancestry-probe; umask 077; : > "$probe"; rm "$probe"'`, - `install -d -o ${DAIMON_BROKER_UID} -g ${DAIMON_BROKER_UID} -m 0750 ${quote(DAIMON_GROK_TURN_USAGE_LEDGER.directoryPath)}`, - `setpriv --clear-groups --reuid ${DAIMON_BROKER_UID} --regid ${DAIMON_BROKER_UID} --inh-caps=-all --ambient-caps=-all --bounding-set=-all -- bash -ceu 'probe=${DAIMON_GROK_TURN_USAGE_LEDGER.directoryPath}/.daimon-usage-probe; umask 027; : > "$probe"; rm "$probe"'`, `setpriv --clear-groups --reuid ${DAIMON_ORGANIZATION_UID} --regid ${DAIMON_ORGANIZATION_UID} --inh-caps=-all --ambient-caps=-all --bounding-set=-all -- bash -ceu '! test -r ${DAIMON_BROKER_REALM}'`, ...resolveDaimonGrokRegistrations(runtimePlans).map((entry) => `setpriv --clear-groups --reuid ${entry.uid} --regid ${entry.uid} --inh-caps=-all --ambient-caps=-all --bounding-set=-all -- bash -ceu '! test -r ${DAIMON_BROKER_REALM}'` diff --git a/src/compiler/containerStateOwnershipRender.ts b/src/compiler/containerStateOwnershipRender.ts index 7e7c88de..51bb1706 100644 --- a/src/compiler/containerStateOwnershipRender.ts +++ b/src/compiler/containerStateOwnershipRender.ts @@ -72,8 +72,9 @@ const createMoltnetPrivacyCommands = ( return [ `install -d -o ${DAIMON_RUNTIME_UID} -g ${DAIMON_RUNTIME_UID} -m 700 ${privateDirectories.map(shellQuote).join(" ")}`, - `chown ${ownership} ${configPaths.map(shellQuote).join(" ")}`, - `chmod 600 ${configPaths.map(shellQuote).join(" ")}` + `chown root:root ${configPaths.map(shellQuote).join(" ")}`, + `chmod 600 ${configPaths.map(shellQuote).join(" ")}`, + `chown ${ownership} ${configPaths.map(shellQuote).join(" ")}` ]; }; diff --git a/src/compiler/durableVolumeReattachDocker.test.ts b/src/compiler/durableVolumeReattachDocker.test.ts index 188882b8..8bb63850 100644 --- a/src/compiler/durableVolumeReattachDocker.test.ts +++ b/src/compiler/durableVolumeReattachDocker.test.ts @@ -6,6 +6,11 @@ import path from "node:path"; import { promisify } from "node:util"; import type { CompileReport } from "../report/index.js"; +import { + DAIMON_WAKE_FUSE_DIRECTORY, + DAIMON_WAKE_FUSE_MOUNT_ID +} from "../runtime/daimon/config.js"; +import { DAIMON_GROK_TURN_USAGE_LEDGER } from "../runtime/daimon/contractManifest.js"; import { createExclusiveReattachVolumeName } from "../shared/index.js"; import type { RuntimeTargetPlan } from "./containerArtifactsTypes.js"; @@ -242,6 +247,52 @@ const resolveDurableMounts = async (plan: RuntimeTargetPlan): Promise\0` key the compiler uses + * (`containerArtifactsPlans.ts`), so they carry no run id — the property the + * assertions below exist to protect. + */ +const daimonOrganizationMounts = (): DurableFixture["storeMount"][] => [ + { + id: DAIMON_WAKE_FUSE_MOUNT_ID, + lifecycle: "exclusive-reattach", + mount_path: DAIMON_WAKE_FUSE_DIRECTORY, + reason: "Daimon durable wake-fuse admission ledger", + volume_name: createExclusiveReattachVolumeName( + `${PLAN_ROOT}\0${DEPLOYMENT_LINEAGE}`, + DAIMON_WAKE_FUSE_MOUNT_ID + ) + }, + { + // Frozen by contract: renaming this id would orphan every existing + // deployment's accumulated ledger, so `config.ts` spells it literally too. + id: "daimon-grok-usage-ledger", + lifecycle: "exclusive-reattach", + mount_path: DAIMON_GROK_TURN_USAGE_LEDGER.directoryPath, + reason: "Daimon per-turn engine usage ledger", + volume_name: createExclusiveReattachVolumeName( + `${PLAN_ROOT}\0${DEPLOYMENT_LINEAGE}`, + "daimon-grok-usage-ledger" + ) + } +]; + const uniqueSuffix = (): string => `${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`; @@ -332,8 +383,10 @@ describe("durable volumes reattach across container replacement", () => { // Never hardcoded: the backing path carries compile-derived hash segments. const resourceMountPath = firstCompile.mounts[0]!.mount_path; + // The organization mounts complete the plan into a valid Daimon org — see + // `daimonOrganizationMounts`. Without them the entrypoint never starts. const allMounts = namespaceMounts( - [...firstCompile.mounts, firstCompile.storeMount], + [...firstCompile.mounts, firstCompile.storeMount, ...daimonOrganizationMounts()], suffix ); const mountArgs = await resolveRunMountArgs(allMounts); @@ -453,8 +506,14 @@ describe("durable volumes reattach across container replacement", () => { let rerunMountArgs: string[]; try { const rerun = await compile(); + // Same organization mounts as the first launch: they are derived from + // the plan root and the deployment lineage alone, so a new run id must + // leave them byte-identical too. rerunMountArgs = await resolveRunMountArgs( - namespaceMounts([...rerun.mounts, rerun.storeMount], suffix) + namespaceMounts( + [...rerun.mounts, rerun.storeMount, ...daimonOrganizationMounts()], + suffix + ) ); } finally { if (rerunPrevious === undefined) delete process.env.NOOPOLIS_RUN_ID; diff --git a/src/compiler/runProject.test.ts b/src/compiler/runProject.test.ts index db169356..548b8755 100644 --- a/src/compiler/runProject.test.ts +++ b/src/compiler/runProject.test.ts @@ -125,6 +125,61 @@ afterEach(async () => { }); describe("createDockerRunInvocation", () => { + it("adds the complete Daimon capability set only to Daimon runs", async () => { + const outputDirectory = await createTempDirectory("spawnfile-daimon-capabilities-"); + const configPath = "/var/lib/spawnfile/instances/daimon/daimon-organization/daimon/runtime.json"; + const configOutputPath = path.join(outputDirectory, "container", "rootfs", configPath); + await ensureDirectory(path.dirname(configOutputPath)); + await writeUtf8File(configOutputPath, JSON.stringify({ + agents: [], + host: {}, + version: "noopolis.daimon.organization-runtime.v1" + })); + const daimonInvocation = await createDockerRunInvocation( + { + organizationReadinessEvidence: genericOrganizationReadinessEvidence, + outputDirectory, + report: createCompileReport({ + runtime_instances: [{ config_path: configPath, id: "daimon", runtime: "daimon" }], + runtimes_installed: ["daimon"] + }), + reportPath: "/tmp/spawnfile-run-out/spawnfile-report.json" + }, + "spawnfile-daimon" + ); + const nonDaimonInvocation = await createDockerRunInvocation( + { + organizationReadinessEvidence: genericOrganizationReadinessEvidence, + outputDirectory: "/tmp/spawnfile-run-out", + report: createCompileReport({ + runtime_instances: [{ config_path: "/picoclaw.json", id: "picoclaw", runtime: "picoclaw" }], + runtimes_installed: ["picoclaw"] + }), + reportPath: "/tmp/spawnfile-run-out/spawnfile-report.json" + }, + "spawnfile-picoclaw" + ); + + const daimonCapabilities = [ + "--cap-drop=ALL", + "--cap-add=CHOWN", + "--cap-add=SETUID", + "--cap-add=SETGID", + "--cap-add=DAC_READ_SEARCH", + "--cap-add=SETPCAP", + "--cap-add=KILL" + ]; + expect(daimonInvocation.args).toEqual(expect.arrayContaining(daimonCapabilities)); + for (const capability of daimonCapabilities) { + expect(nonDaimonInvocation.args).not.toContain(capability); + } + + await Promise.all([ + removeDirectory(daimonInvocation.supportDirectory), + removeDirectory(nonDaimonInvocation.supportDirectory) + ]); + }); + it("writes env files, publishes ports, and mounts imported auth", async () => { const spawnfileHome = await createTempDirectory("spawnfile-auth-home-"); process.env.SPAWNFILE_HOME = spawnfileHome; diff --git a/src/compiler/runProject.ts b/src/compiler/runProject.ts index 47944d4d..67c6f6ae 100644 --- a/src/compiler/runProject.ts +++ b/src/compiler/runProject.ts @@ -174,6 +174,10 @@ export const createDockerRunInvocation = async ( "--cap-add=SETUID", "--cap-add=SETGID", "--cap-add=DAC_READ_SEARCH", + // Lets the broker launcher drop its bounding set to 00000000000000c1; without SETPCAP, setpriv --bounding-set silently no-ops. + "--cap-add=SETPCAP", + // The entrypoint supervises children dropped to uid 2100/2200+; without CAP_KILL, root cannot even kill -0 them, so a live child reads as dead. + "--cap-add=KILL", "--security-opt=no-new-privileges:true" ); } diff --git a/src/compiler/workspaceBundleArtifacts.test.ts b/src/compiler/workspaceBundleArtifacts.test.ts index 45f8f9de..36400485 100644 --- a/src/compiler/workspaceBundleArtifacts.test.ts +++ b/src/compiler/workspaceBundleArtifacts.test.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; import { execFile } from "node:child_process"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, truncate, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; @@ -9,6 +9,15 @@ import { describe, expect, it } from "vitest"; import { stageWorkspaceBundles, validateWorkspaceBundleTar } from "./workspaceBundleArtifacts.js"; const run = promisify(execFile); +const tarWithEmptyEntries = (count: number): Buffer => { + const tar = Buffer.alloc((count + 2) * 512); + for (let index = 0; index < count; index += 1) { + const header = tar.subarray(index * 512, (index + 1) * 512); + header.write(`entry-${index}`, 0, "ascii"); header.write("0000000", 100, "ascii"); header.write("00000000000", 124, "ascii"); header[156] = "0".charCodeAt(0); header.write("ustar", 257, "ascii"); + header.fill(32, 148, 156); let checksum = 0; for (const byte of header) checksum += byte; header.write(`${checksum.toString(8).padStart(6, "0")}\0 `, 148, "ascii"); + } + return tar; +}; describe("offline workspace bundles", () => { it("returns false without declarations and rejects unsafe archive identities", async () => { @@ -71,4 +80,33 @@ describe("offline workspace bundles", () => { await expect(stageWorkspaceBundles(path.join(root, "bad"), { nodes: [{ kind: "agent", value: { workspaceResources: [resource] } }] } as never)).rejects.toThrow(/checksum mismatch/u); } finally { await rm(root, { recursive: true, force: true }); } }); + it("accepts a workspace bundle above the former 10,000-entry bound", () => { + expect(() => validateWorkspaceBundleTar(tarWithEmptyEntries(10_001))).not.toThrow(); + }); + it("rejects a workspace bundle above the 65,536-entry bound with the entry-count message", () => { + expect(() => validateWorkspaceBundleTar(tarWithEmptyEntries(65_537))).toThrow("Workspace bundle exceeds the maximum entry count"); + }); + it("reports a genuinely truncated workspace bundle with the truncation message", () => { + const truncated = tarWithEmptyEntries(1).subarray(0, 1024); truncated.fill(0, 124, 136); truncated.write("00000002000", 124, "ascii"); + truncated.fill(32, 148, 156); let checksum = 0; for (const byte of truncated.subarray(0, 512)) checksum += byte; truncated.write(`${checksum.toString(8).padStart(6, "0")}\0 `, 148, "ascii"); + expect(() => validateWorkspaceBundleTar(truncated)).toThrow("Workspace bundle is truncated or exceeds entry bounds"); + }); + it("rejects a workspace bundle just over the 512 MiB cap", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "spawnfile-bundle-over-cap-")); + try { + const source = path.join(root, "oversized.tar"); await writeFile(source, ""); await truncate(source, 536_870_913); + const resource = { id: "oversized", kind: "bundle", mode: "readonly", mount: "./oversized", sha256: `sha256:${"0".repeat(64)}`, source: "oversized.tar", sharing: "per_agent", scope: { kind: "agent", key: path.join(root, "Agentfile"), name: "agent" } }; + await expect(stageWorkspaceBundles(path.join(root, "out"), { nodes: [{ kind: "agent", value: { workspaceResources: [resource] } }] } as never)).rejects.toThrow("Workspace bundle must be a bounded regular tar file"); + } finally { await rm(root, { recursive: true, force: true }); } + }); + it("accepts a workspace bundle above the former 64 MiB cap", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "spawnfile-bundle-above-old-cap-")); + try { + await writeFile(path.join(root, "tracked.txt"), "tracked"); await run("tar", ["--format=ustar", "-cf", "bundle.tar", "tracked.txt"], { cwd: root }); + const source = path.join(root, "bundle.tar"); await truncate(source, 67_109_376); + const bytes = await readFile(source); const sha256 = `sha256:${createHash("sha256").update(bytes).digest("hex")}`; + const resource = { id: "large", kind: "bundle", mode: "readonly", mount: "./large", sha256, source: "bundle.tar", sharing: "per_agent", scope: { kind: "agent", key: path.join(root, "Agentfile"), name: "agent" } }; + await expect(stageWorkspaceBundles(path.join(root, "out"), { nodes: [{ kind: "agent", value: { workspaceResources: [resource] } }] } as never)).resolves.toBe(true); + } finally { await rm(root, { recursive: true, force: true }); } + }); }); diff --git a/src/compiler/workspaceBundleArtifacts.ts b/src/compiler/workspaceBundleArtifacts.ts index 116c4993..24028c1b 100644 --- a/src/compiler/workspaceBundleArtifacts.ts +++ b/src/compiler/workspaceBundleArtifacts.ts @@ -4,7 +4,10 @@ import path from "node:path"; import { SpawnfileError } from "../shared/index.js"; import type { CompilePlan } from "./types.js"; -const CAP = 67_108_864, BLOCK = 512; +// Sanity bound for an operator-declared local tar; integrity comes from its digest, not its size. +const CAP = 536_870_912, BLOCK = 512; +// Sanity bound on an operator-declared local tar; integrity comes from the declared digest. +const MAX_WORKSPACE_BUNDLE_ENTRIES = 65_536; const fail = (message = "Workspace bundle contains an invalid or unsafe tar entry"): never => { throw new SpawnfileError("validation_error", message); }; const textField = (field: Buffer): string => { const nul = field.indexOf(0); return field.subarray(0, nul < 0 ? field.length : nul).toString("utf8"); }; const octal = (field: Buffer, allowEmpty = false): number => { @@ -39,7 +42,9 @@ export const validateWorkspaceBundleTar = (bytes: Buffer): void => { const size = octal(header.subarray(124, 136)); if (type === "5" && size !== 0) fail(); const prefix = textField(header.subarray(345, 500)), rawName = textField(header.subarray(0, 100)); const effective = validPath(prefix ? `${prefix}/${rawName}` : rawName, type === "5"); if (names.has(effective)) fail("Workspace bundle contains duplicate effective paths"); names.add(effective); - offset += BLOCK + Math.ceil(size / BLOCK) * BLOCK; entries += 1; if (offset > bytes.length || entries > 10_000) fail("Workspace bundle is truncated or exceeds entry bounds"); + offset += BLOCK + Math.ceil(size / BLOCK) * BLOCK; entries += 1; + if (offset > bytes.length) fail("Workspace bundle is truncated or exceeds entry bounds"); + if (entries > MAX_WORKSPACE_BUNDLE_ENTRIES) fail("Workspace bundle exceeds the maximum entry count"); } if (!terminated) fail("Workspace bundle is truncated or lacks exact ustar termination"); if (entries === 0) fail("Workspace bundle is empty"); }; diff --git a/src/manifest/scheduleSchemas.ts b/src/manifest/scheduleSchemas.ts index 0f11f808..ec33e2c3 100644 --- a/src/manifest/scheduleSchemas.ts +++ b/src/manifest/scheduleSchemas.ts @@ -58,11 +58,22 @@ const cronValues = (field: string, [minimum, maximum]: readonly [number, number] const everySchema = z.string().trim().min(1).superRefine((value, context) => { if (parseEveryScheduleMs(value) === null) context.addIssue({ code: z.ZodIssueCode.custom, message: "every must be a positive duration" }); }); +/** + * Upper bound on `schedule.jitter_seconds`, mirroring Daimon's + * `ORGANIZATION_RUNTIME_MAX_SCHEDULE_JITTER_SECONDS` so every jitter value this + * schema accepts also lowers cleanly into the Daimon runtime config: one hour, + * generous next to the finest cron granularity (a minute) while staying small + * relative to the daily/sub-daily cadences jitter exists to blur, so a + * jittered wake still lands recognizably near its scheduled instant. + */ +const MAX_SCHEDULE_JITTER_SECONDS = 3_600; +const jitterSecondsSchema = z.number().int().min(0).max(MAX_SCHEDULE_JITTER_SECONDS); export const agentScheduleSchema = z.discriminatedUnion("kind", [ z .object({ cron: cronSchema, + jitter_seconds: jitterSecondsSchema.optional(), kind: z.literal("cron"), prompt: schedulePromptSchema.optional(), timezone: scheduleTimezoneSchema.optional() @@ -71,6 +82,7 @@ export const agentScheduleSchema = z.discriminatedUnion("kind", [ z .object({ every: everySchema, + jitter_seconds: jitterSecondsSchema.optional(), kind: z.literal("every"), prompt: schedulePromptSchema.optional(), timezone: scheduleTimezoneSchema.optional() diff --git a/src/manifest/schemas.test.ts b/src/manifest/schemas.test.ts index a40eb22e..38800f88 100644 --- a/src/manifest/schemas.test.ts +++ b/src/manifest/schemas.test.ts @@ -827,6 +827,17 @@ describe("manifestSchema", () => { expect(normalized.schedule?.kind === "cron" ? normalized.schedule.cron : undefined).toBe("0 5 * * *"); }); + it("accepts schedule.jitter_seconds within Daimon's bound and rejects out-of-range or non-integer values", () => { + const parses = (schedule: unknown) => manifestSchema.safeParse({ kind: "agent", name: "agent", runtime: "daimon", schedule, spawnfile_version: "0.1" }).success; + expect(parses({ kind: "cron", cron: "0 10 * * *", timezone: "Europe/Berlin", prompt: "work", jitter_seconds: 900 })).toBe(true); + expect(parses({ kind: "every", every: "5m", prompt: "work", jitter_seconds: 0 })).toBe(true); + expect(parses({ kind: "every", every: "5m", prompt: "work", jitter_seconds: 3_600 })).toBe(true); + expect(parses({ kind: "every", every: "5m", prompt: "work", jitter_seconds: 3_601 })).toBe(false); + expect(parses({ kind: "every", every: "5m", prompt: "work", jitter_seconds: -1 })).toBe(false); + expect(parses({ kind: "every", every: "5m", prompt: "work", jitter_seconds: 1.5 })).toBe(false); + expect(parses({ kind: "disabled", jitter_seconds: 10 })).toBe(false); + }); + it("rejects schedules on team manifests", () => { const result = manifestSchema.safeParse({ kind: "team", diff --git a/src/runtime/container.test.ts b/src/runtime/container.test.ts index b22759b4..5e7616cd 100644 --- a/src/runtime/container.test.ts +++ b/src/runtime/container.test.ts @@ -5,6 +5,10 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { NOOPOLIS_RUN_ID_ENV } from "./common.js"; +import { + DAIMON_WAKE_FUSE_DIRECTORY, + DAIMON_WAKE_FUSE_DIRECTORY_ENV +} from "./daimon/config.js"; import { createRuntimeContainerEnv, createRuntimeInstallRecipe, RUNTIME_INSTALL_ROOT } from "./container.js"; import { DAIMON_CONTRACT_MANIFEST_SHA256 } from "./daimon/contractManifest.js"; const LOCAL_DAIMON_IMAGE_REPOSITORY = "127.0.0.1:54321/noopolis/spawnfile-runtime-daimon"; @@ -154,6 +158,9 @@ describe("runtime container install recipes", () => { expect(recipe.copyCommands).toEqual([ `COPY --from=${LOCAL_DAIMON_IMAGE_REPOSITORY}@${testDigest("c")} ${RUNTIME_INSTALL_ROOT}/daimon ${RUNTIME_INSTALL_ROOT}/daimon` ]); + expect(recipe.env).toEqual({ + [DAIMON_WAKE_FUSE_DIRECTORY_ENV]: DAIMON_WAKE_FUSE_DIRECTORY + }); }); it("rejects raw Daimon image overrides instead of treating them as local authority", async () => { diff --git a/src/runtime/container.ts b/src/runtime/container.ts index 4c46dae3..1be8b529 100644 --- a/src/runtime/container.ts +++ b/src/runtime/container.ts @@ -13,6 +13,10 @@ import { loadLocalDaimonRuntimeIdentity } from "./localDaimonAuthority.js"; import { DAIMON_CONTRACT_MANIFEST_SHA256 } from "./daimon/contractManifest.js"; +import { + DAIMON_WAKE_FUSE_DIRECTORY, + DAIMON_WAKE_FUSE_DIRECTORY_ENV +} from "./daimon/config.js"; export const RUNTIME_INSTALL_ROOT = "/opt/spawnfile/runtime-installs"; const PI_RUNTIME_BASE_IMAGE_ENV = "SPAWNFILE_PI_RUNTIME_BASE_IMAGE"; @@ -245,7 +249,10 @@ export const createRuntimeInstallRecipe = async ( `mkdir -p /opt/daimon/bin && install -o root -g root -m 0555 ${installRoot}/bin/daimon-engine-broker /opt/daimon/bin/daimon-engine-broker && arch="$(dpkg --print-architecture)" && case "$arch" in amd64) expected=e3fe2738fc8a979861085b4003bf2d5d7c284874897cb6ec2e2e2383211768bd ;; arm64) expected=ad44e02c38e6a3207ac4a3d5fd98b6d2e55341ce42dfd2f07204bbe54a7a653d ;; *) exit 1 ;; esac && test "$(sha256sum /opt/daimon/bin/daimon-engine-broker | awk '{print $1}')" = "$expected"` ], copyCommands: [createRuntimeImageCopyCommand(daimonRuntime.image, installRoot)], - env: containerEnv, + env: { + ...containerEnv, + [DAIMON_WAKE_FUSE_DIRECTORY_ENV]: DAIMON_WAKE_FUSE_DIRECTORY + }, runtimeName, runtimeRoot: installRoot }; diff --git a/src/runtime/daimon/AGENTS.md b/src/runtime/daimon/AGENTS.md index 3c83e952..b8666044 100644 --- a/src/runtime/daimon/AGENTS.md +++ b/src/runtime/daimon/AGENTS.md @@ -22,9 +22,32 @@ volume name folds in the run id (`createPersistentVolumeName`), so a fresh `spawnfile up` gets an empty volume. For the AGY realm that means an empty OS keyring and a repeat of the interactive browser OAuth, which has no headless equivalent; for the ledger it means cross-deployment accounting is impossible. -The usage ledger is provisioned for any organization containing a metered -engine — AGY or Grok — and its mount id stays `daimon-grok-usage-ledger` -because the id is the volume identity and renaming it orphans existing data. +The usage ledger is provisioned unconditionally, for every Daimon +organization — Codex writes its advisory per-turn usage here too, not just +AGY and Grok, and Daimon's wake fuse now refuses to arm at all if this +directory is missing or unreadable (`ensureUsageLedgerReadable` in Daimon's +`wakeFuse.ts`). Its mount id stays `daimon-grok-usage-ledger` because the id +is the volume identity and renaming it orphans existing data. The container +entrypoint also fixes this directory group-writable (`0770`, not `0750`): +Codex and AGY write from the organization-uid runtime process +(`renderDaimonUsageLedgerProvisioning` in `containerDaimonBrokerRender.ts`), +not the Grok-only broker, so a mode granting the group only read+execute lets +that process list the directory but not create `usage.jsonl` inside it. That +fix-up is `chown root, chmod, chown to the final broker:organization owner` +— in that order, never `install -d`'s create-then-chown-then-chmod — because +root only ever chmods a path it currently owns: it has `CAP_CHOWN` but not +`CAP_FOWNER` (`runProject.ts`'s capability set), so chmod-ing *after* handing +ownership to the broker uid fails closed with `EPERM`. And it never creates +the directory itself: `/var/lib/spawnfile/daimon` (the shared parent every +Daimon-under-`/var/lib/spawnfile/daimon` mount lives under — AGY/Grok realms, +wake fuse, broker realm, this ledger) must stay a shared, root-owned, +universally traversable ancestor (`secureFixedTraversalAncestor`, +`containerDaimonOwnershipGuardRender.ts`), never chowned to the organization +uid by the generic per-mount ancestor walk that every other private directory +goes through — an organization with an AGY or Grok agent would otherwise +leave that shared parent owned by the organization uid as a side effect of +securing its own realm mount, which then blocks *every other* differently-owned +child (concretely, this ledger) from ever being created there at all. All three engines lower declared MCP servers and Moltnet surfaces. AGY was excluded until Daimon learned to register its per-wake MCP endpoint through diff --git a/src/runtime/daimon/adapter.test.ts b/src/runtime/daimon/adapter.test.ts index fade4a55..2dd311f6 100644 --- a/src/runtime/daimon/adapter.test.ts +++ b/src/runtime/daimon/adapter.test.ts @@ -12,7 +12,11 @@ import { createRuntimeInstallRecipe } from "../container.js"; import { createPiTestNode } from "../pi/testHelpers.js"; import { daimonAdapter } from "./adapter.js"; -import { DAIMON_CONFIG_FILE } from "./config.js"; +import { + DAIMON_CONFIG_FILE, + DAIMON_WAKE_FUSE_DIRECTORY, + DAIMON_WAKE_FUSE_DIRECTORY_ENV +} from "./config.js"; const createDaimonNode = (id: string, name = id, engine = "codex") => { const node = createPiTestNode({ @@ -86,6 +90,38 @@ describe("daimonAdapter", () => { }); }); + it("mounts the durable wake-fuse ledger and the per-turn usage ledger for a codex-only organization", async () => { + const node = createDaimonNode("codex-only", "Codex Only"); + const compiled = await daimonAdapter.compileAgent(node); + const target = (await daimonAdapter.createContainerTargets!([{ + emittedFiles: compiled.files, + id: "agent:codex-only", + kind: "agent", + slug: "codex-only", + value: node + }]))[0]!; + + // Mutation-critical: gating this mount on Grok or AGY must turn this red. + expect(target.persistentMounts).toContainEqual({ + id: "daimon-wake-fuse", + lifecycle: "exclusive-reattach", + mountPath: "/var/lib/spawnfile/daimon/wake-fuse", + reason: "Daimon durable wake-fuse admission ledger" + }); + // Codex writes its advisory per-turn usage here too (`engineDispatcher.ts`'s + // `onTurnUsage`, daimon side), and Daimon's wake fuse now refuses to arm at + // all if this directory is missing (`wakeFuse.ts`'s + // `ensureUsageLedgerReadable`) — so a codex-only organization needs this + // mount exactly as much as a Grok or AGY one does. Mutation-critical: + // re-gating this mount on `hasGrok || hasAgy` must turn this red. + expect(target.persistentMounts).toContainEqual({ + id: "daimon-grok-usage-ledger", + lifecycle: "exclusive-reattach", + mountPath: "/var/lib/spawnfile/daimon/usage", + reason: "Daimon per-turn engine usage ledger" + }); + }); + it("creates physical per-agent roots and invokes only the public daemon command", async () => { const plan = await createPlan(); const rootfs = createRootfsFiles([plan]); @@ -107,6 +143,14 @@ describe("daimonAdapter", () => { expect(daimonAdapter.container.systemDeps).toEqual([ "bash", "bubblewrap", "ca-certificates", "curl", "dbus-daemon", "gnome-keyring", "util-linux" ]); + + const envEntrypoint = renderEntrypoint([{ + ...plan, + recipeEnv: { [DAIMON_WAKE_FUSE_DIRECTORY_ENV]: DAIMON_WAKE_FUSE_DIRECTORY } + }], []); + expect(envEntrypoint).toContain( + `DAIMON_WAKE_FUSE_DIRECTORY='/var/lib/spawnfile/daimon/wake-fuse' exec` + ); }); it("compiles and mounts a three-engine Moltnet trace without invoking an engine", async () => { @@ -186,6 +230,12 @@ describe("daimonAdapter", () => { mountPath: "/state/wake-acceptance", reason: "Daimon organization durable wake acceptance store" }, + { + id: "daimon-wake-fuse", + lifecycle: "exclusive-reattach", + mountPath: "/var/lib/spawnfile/daimon/wake-fuse", + reason: "Daimon durable wake-fuse admission ledger" + }, { id: "daimon-grok-subscription-realm", lifecycle: "exclusive-reattach", @@ -272,6 +322,12 @@ describe("daimonAdapter", () => { mountPath: "/state/wake-acceptance", reason: "Daimon organization durable wake acceptance store" }, + { + id: "daimon-wake-fuse", + lifecycle: "exclusive-reattach", + mountPath: "/var/lib/spawnfile/daimon/wake-fuse", + reason: "Daimon durable wake-fuse admission ledger" + }, { id: "daimon-grok-subscription-realm", lifecycle: "exclusive-reattach", @@ -448,7 +504,7 @@ describe("daimonAdapter", () => { const oversized = { ...createDaimonNode("oversized"), - docs: [{ content: "x".repeat(4_097), path: "AGENTS.md", role: "instructions" }] + docs: [{ content: "x".repeat(16_385), path: "AGENTS.md", role: "instructions" }] } as any; await expect(daimonAdapter.createContainerTargets!([{ emittedFiles: [], id: "agent:oversized", kind: "agent", slug: "oversized", value: oversized diff --git a/src/runtime/daimon/config.ts b/src/runtime/daimon/config.ts index 934ab5d7..42c6ba0a 100644 --- a/src/runtime/daimon/config.ts +++ b/src/runtime/daimon/config.ts @@ -38,10 +38,13 @@ export const DAIMON_RUNTIME_ACCEPTANCE_STORE_DIRECTORY = "state/wake-acceptance" export const DAIMON_RUNTIME_ACCEPTANCE_STORE_ENV = "DAIMON_RUNTIME_ACCEPTANCE_STORE"; export const DAIMON_RUNTIME_ACCEPTANCE_STORE_MOUNT_ID = "daimon-organization-acceptance-store"; export const DAIMON_RUNTIME_READINESS_RECEIPT_ENV = "DAIMON_RUNTIME_READINESS_RECEIPT"; +export const DAIMON_WAKE_FUSE_DIRECTORY = "/var/lib/spawnfile/daimon/wake-fuse"; +export const DAIMON_WAKE_FUSE_DIRECTORY_ENV = "DAIMON_WAKE_FUSE_DIRECTORY"; +export const DAIMON_WAKE_FUSE_MOUNT_ID = "daimon-wake-fuse"; export const DAIMON_RUNTIME_HOMES_DIRECTORY = "runtime-homes"; const DAIMON_MAX_CONFIG_BYTES = 1_048_576; const DAIMON_MAX_INSTRUCTION_BYTES = 16_384; -const DAIMON_MAX_INSTRUCTION_CODEPOINTS = 4_096; +const DAIMON_MAX_INSTRUCTION_CODEPOINTS = 16_384; export const DAIMON_ENGINES = ["agy", "codex", "grok"] as const; type DaimonEngine = typeof DAIMON_ENGINES[number]; @@ -51,13 +54,17 @@ const normalizeSchedule = (node: ResolvedAgentNode): Record | u if (schedule.kind === "every") { const interval_ms = parseEveryScheduleMs(schedule.every); if (interval_ms === null) throw new SpawnfileError("validation_error", `invalid every schedule for ${node.name}`); - return { kind: "every", interval_ms, prompt: schedule.prompt ?? "Scheduled work" }; + return { + kind: "every", interval_ms, prompt: schedule.prompt ?? "Scheduled work", + ...(schedule.jitter_seconds === undefined ? {} : { jitter_seconds: schedule.jitter_seconds }) + }; } return { cron: schedule.cron.trim().replace(/\s+/gu, " "), kind: "cron", prompt: schedule.prompt ?? "Scheduled work", - timezone: schedule.timezone ?? "UTC" + timezone: schedule.timezone ?? "UTC", + ...(schedule.jitter_seconds === undefined ? {} : { jitter_seconds: schedule.jitter_seconds }) }; }; @@ -106,8 +113,11 @@ const renderStartScript = (agents: Array<{ : undefined; const inbound = path.posix.join(agent.runtimeHomePath, ".daimon-inbound"); return [ + // The workspace mode is owned by the uid entrypoint, which grants a grok worker + // group access. Forcing 0700 here would lock that worker out of its own workspace, + // so create it only when absent and never restate the mode of an existing directory. + `[ -d ${JSON.stringify(agent.workspacePath)} ] || install -d -m 700 ${JSON.stringify(agent.workspacePath)}`, `install -d -m 700 ${[ - agent.workspacePath, agent.runtimeHomePath, ...(credential === undefined ? [] : [inbound]) ].map((entry) => JSON.stringify(entry)).join(" ")}`, @@ -236,27 +246,37 @@ export const createDaimonContainerTargets = async ( id: DAIMON_RUNTIME_ACCEPTANCE_STORE_MOUNT_ID, mountPath: `/${DAIMON_RUNTIME_ACCEPTANCE_STORE_DIRECTORY}`, reason: "Daimon organization durable wake acceptance store" + }, { + id: DAIMON_WAKE_FUSE_MOUNT_ID, + lifecycle: "exclusive-reattach" as const, + mountPath: DAIMON_WAKE_FUSE_DIRECTORY, + reason: "Daimon durable wake-fuse admission ledger" }, ...(hasGrok ? [{ id: "daimon-grok-subscription-realm", lifecycle: "exclusive-reattach" as const, mountPath: DAIMON_GROK_SUBSCRIPTION_REALM.durableMountPath, reason: "Daimon host Grok subscription credential realm" - }] : []), ...(hasGrok || hasAgy ? [{ + }] : []), { // Non-run-scoped for the same reason as the durable memory mounts (see // durableMemoryVolumeName in src/compiler/memoryArtifacts.ts): a // run-scoped volume means every `spawnfile up` starts a new empty // ledger and cross-deployment usage accounting is impossible. The - // broker is the single writer and rotates this log by size, so the // exclusive reservation this lifecycle carries is a requirement, not a // cost. - // The mount id is deliberately unchanged now that AGY writes here too: - // it is the volume's identity, and renaming it would orphan every - // existing deployment's accumulated ledger. + // The mount id is deliberately unchanged now that AGY and Codex write + // here too, not just the broker: it is the volume's identity, and + // renaming it would orphan every existing deployment's accumulated + // ledger. Unconditional (not gated on hasGrok/hasAgy) because Daimon's + // wake fuse now refuses to arm at all if this directory or the ledger + // file inside it is missing or unreadable (`wakeFuse.ts`, + // `ensureUsageLedgerReadable`), and Codex also writes here + // (`engineDispatcher.ts`'s `onTurnUsage`) even in a codex-only + // organization with no Grok or AGY agent at all. id: "daimon-grok-usage-ledger", lifecycle: "exclusive-reattach" as const, mountPath: DAIMON_GROK_TURN_USAGE_LEDGER.directoryPath, reason: "Daimon per-turn engine usage ledger" - }] : []), ...(hasAgy ? [{ + }, ...(hasAgy ? [{ id: "daimon-agy-subscription-realm", // The AGY subscription credential is an OS-keyring entry created by an // interactive browser OAuth that has no headless equivalent; it lives diff --git a/src/runtime/daimon/contract-manifest.json b/src/runtime/daimon/contract-manifest.json index 69721f05..0d175091 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":{"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","queue_full","unknown_agent"]},"delivery_id":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"queue_position":{"minimum":1,"type":"integer"},"request_digest":{"type":"string"},"state":{"enum":["accepted","running","completed","failed","stopped"]},"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"},"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"},"consumedConfigFields":["version","host.bindHost","host.port","host.controlTokenEnv","agents[].id","agents[].name","agents[].instructions","agents[].workspacePath","agents[].runtimeHomePath","agents[].engine.kind","agents[].schedule.kind","agents[].schedule.interval_ms","agents[].schedule.cron","agents[].schedule.timezone","agents[].schedule.prompt","agents[].mcp","agents[].moltnet","agents[].memory"],"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":"ad44e02c38e6a3207ac4a3d5fd98b6d2e55341ce42dfd2f07204bbe54a7a653d","sourceSha256":"bdcab1e12dcc531ed8e56f890263ca23a9ee7bac468191dd598e143df4ff8c58","x64Sha256":"e3fe2738fc8a979861085b4003bf2d5d7c284874897cb6ec2e2e2383211768bd"},"backendSocketPath":"/run/daimon-engine-broker/backend.sock","bounds":{"capabilityBundleBytes":8196,"capabilityBytes":4096,"outputBytes":65536,"promptBytes":65536},"controlSocketPath":"/run/daimon-engine-broker/control.sock","credentialHomePath":"/var/lib/spawnfile/daimon/grok-subscription-realm","grokExecutablePath":"/usr/local/bin/grok","identities":{"brokerUid":2100,"firstWorkerUid":2200,"organizationUid":2000},"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","providerProxy":{"host":"127.0.0.1","port":43123},"registrationPath":"/etc/daimon-engine-broker/registrations.bin","serviceConfigPath":"/etc/daimon-engine-broker/service.json","turnStorePath":"/var/lib/spawnfile/daimon/grok-subscription-realm/turns"},"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"},"organizationRuntimeConfigSchema":{"$id":"noopolis.daimon.organization-runtime.v1","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"agents":{"items":{"additionalProperties":false,"properties":{"engine":{"additionalProperties":false,"properties":{"kind":{"enum":["codex","grok","agy"]}},"required":["kind"],"type":"object"},"id":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"instructions":{"maxLength":4096,"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":{"engine":{"additionalProperties":false,"properties":{"kind":{"enum":["codex","grok","agy"]}},"required":["kind"],"type":"object"},"id":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"instructions":{"maxLength":4096,"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"},"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"},"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"]},"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"}]}} +{"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":{"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","queue_full","unknown_agent"]},"delivery_id":{"maxLength":4096,"minLength":1,"pattern":"\\S","type":"string"},"queue_position":{"minimum":1,"type":"integer"},"request_digest":{"type":"string"},"state":{"enum":["accepted","running","completed","failed","stopped"]},"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"},"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"},"consumedConfigFields":["version","host.bindHost","host.port","host.controlTokenEnv","agents[].id","agents[].name","agents[].instructions","agents[].workspacePath","agents[].runtimeHomePath","agents[].engine.kind","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"],"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":"ad44e02c38e6a3207ac4a3d5fd98b6d2e55341ce42dfd2f07204bbe54a7a653d","sourceSha256":"bdcab1e12dcc531ed8e56f890263ca23a9ee7bac468191dd598e143df4ff8c58","x64Sha256":"e3fe2738fc8a979861085b4003bf2d5d7c284874897cb6ec2e2e2383211768bd"},"backendSocketPath":"/run/daimon-engine-broker/backend.sock","bounds":{"capabilityBundleBytes":8196,"capabilityBytes":4096,"outputBytes":65536,"promptBytes":65536},"controlSocketPath":"/run/daimon-engine-broker/control.sock","credentialHomePath":"/var/lib/spawnfile/daimon/grok-subscription-realm","grokExecutablePath":"/usr/local/bin/grok","identities":{"brokerUid":2100,"firstWorkerUid":2200,"organizationUid":2000},"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","providerProxy":{"host":"127.0.0.1","port":43123},"registrationPath":"/etc/daimon-engine-broker/registrations.bin","serviceConfigPath":"/etc/daimon-engine-broker/service.json","turnStorePath":"/var/lib/spawnfile/daimon/grok-subscription-realm/turns"},"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"},"organizationRuntimeConfigSchema":{"$id":"noopolis.daimon.organization-runtime.v1","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"agents":{"items":{"additionalProperties":false,"properties":{"engine":{"additionalProperties":false,"properties":{"kind":{"enum":["codex","grok","agy"]}},"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":{"engine":{"additionalProperties":false,"properties":{"kind":{"enum":["codex","grok","agy"]}},"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"]},"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"}]}} diff --git a/src/runtime/daimon/contract-manifest.sha256 b/src/runtime/daimon/contract-manifest.sha256 index 3f07dbaf..f535d762 100644 --- a/src/runtime/daimon/contract-manifest.sha256 +++ b/src/runtime/daimon/contract-manifest.sha256 @@ -1 +1 @@ -sha256:575788f5abb6e82cb6c163c76996d1efe8e06bb9a15c4a942617f6a20646bfed +sha256:444508888e9432f47d423dd996c0556823877c387a5715426cd4c315f28e8698 diff --git a/src/runtime/daimon/contractManifest.test.ts b/src/runtime/daimon/contractManifest.test.ts index d84042f3..ffcd00ef 100644 --- a/src/runtime/daimon/contractManifest.test.ts +++ b/src/runtime/daimon/contractManifest.test.ts @@ -49,7 +49,8 @@ const manifest = () => ({ "agents[].name", "agents[].instructions", "agents[].workspacePath", "agents[].runtimeHomePath", "agents[].engine.kind", "agents[].schedule.kind", "agents[].schedule.interval_ms", "agents[].schedule.cron", "agents[].schedule.timezone", - "agents[].schedule.prompt", "agents[].mcp", "agents[].moltnet", "agents[].memory" + "agents[].schedule.prompt", "agents[].schedule.jitter_seconds", + "agents[].mcp", "agents[].moltnet", "agents[].memory" ], engineCredentialMaterial: { codex: { destinationRelativePath: ".codex/auth.json", directoryMode: 0o700, fileMode: 0o600, sourceRelativePath: ".daimon-inbound/codex-auth", sourceSlot: "codex-auth" }, diff --git a/src/runtime/daimon/contractManifest.ts b/src/runtime/daimon/contractManifest.ts index 8b5777a4..21a74974 100644 --- a/src/runtime/daimon/contractManifest.ts +++ b/src/runtime/daimon/contractManifest.ts @@ -7,7 +7,7 @@ import { SpawnfileError } from "../../shared/index.js"; export const DAIMON_CONTRACT_MANIFEST_VERSION = "noopolis.daimon.runtime-contract-manifest.v3" as const; export const DAIMON_CONTRACT_MANIFEST_SHA256 = - "sha256:575788f5abb6e82cb6c163c76996d1efe8e06bb9a15c4a942617f6a20646bfed" as const; + "sha256:444508888e9432f47d423dd996c0556823877c387a5715426cd4c315f28e8698" 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"; @@ -123,6 +123,7 @@ const expectedConfigFields = [ "agents[].runtimeHomePath", "agents[].engine.kind", "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" ] as const; const exactKeys = (value: Record, keys: readonly string[]): boolean => diff --git a/src/runtime/daimon/scheduleAuthority.test.ts b/src/runtime/daimon/scheduleAuthority.test.ts index 83ce0030..71be690b 100644 --- a/src/runtime/daimon/scheduleAuthority.test.ts +++ b/src/runtime/daimon/scheduleAuthority.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; const LOCAL_DAIMON_IMAGE_REPOSITORY = "127.0.0.1:54321/noopolis/spawnfile-runtime-daimon"; +import type { ResolvedAgentNode } from "../../compiler/types.js"; import { createPiTestNode } from "../pi/testHelpers.js"; import { daimonAdapter } from "./adapter.js"; import { DAIMON_CONFIG_FILE } from "./config.js"; @@ -63,4 +64,25 @@ describe("Daimon schedule image authority", () => { process.env.SPAWNFILE_DAIMON_LOCAL_RUNTIME_IDENTITY = await identity(digest("d")); await expect(scheduledTarget()).rejects.toThrow(/Local Daimon runtime identity is invalid or incomplete/u); }); + + it("lowers schedule.jitter_seconds into the v2 config for cron and every schedules, and omits it when absent", async () => { + process.env.SPAWNFILE_DAIMON_LOCAL_RUNTIME_IDENTITY = await identity(); + const configFor = async (schedule: NonNullable, slug: string) => { + const node = createPiTestNode({ runtime: { name: "daimon", options: {} }, schedule }); + const compiled = await daimonAdapter.compileAgent(node); + const targets = await daimonAdapter.createContainerTargets!([{ emittedFiles: compiled.files, id: `agent:${slug}`, kind: "agent", slug, value: node }]); + return JSON.parse(targets[0]!.files.find((file) => file.path === DAIMON_CONFIG_FILE)!.content); + }; + + const cronConfig = await configFor({ kind: "cron", cron: "0 10 * * *", timezone: "Europe/Berlin", prompt: "work", jitter_seconds: 900 }, "cron"); + expect(cronConfig.agents[0].schedule).toEqual({ + cron: "0 10 * * *", jitter_seconds: 900, kind: "cron", prompt: "work", timezone: "Europe/Berlin" + }); + + const everyConfig = await configFor({ kind: "every", every: "5m", prompt: "work", jitter_seconds: 30 }, "every-jittered"); + expect(everyConfig.agents[0].schedule).toEqual({ interval_ms: 300_000, jitter_seconds: 30, kind: "every", prompt: "work" }); + + const noJitterConfig = await configFor({ kind: "every", every: "5m", prompt: "work" }, "every-plain"); + expect(noJitterConfig.agents[0].schedule).not.toHaveProperty("jitter_seconds"); + }); }); diff --git a/src/runtime/localDaimonAuthority.ts b/src/runtime/localDaimonAuthority.ts index 90f4c831..7316d337 100644 --- a/src/runtime/localDaimonAuthority.ts +++ b/src/runtime/localDaimonAuthority.ts @@ -24,7 +24,7 @@ const localDaimonRuntimeIdentitySchema = z unsigned: z.literal(true) }) .strict(), - image_architecture: z.literal("amd64"), + image_architecture: z.union([z.literal("amd64"), z.literal("arm64")]), image_config_digest: z.string().regex(DIGEST), image_manifest_digest: z.string().regex(DIGEST), image_reference: z.string(), @@ -46,7 +46,7 @@ const localDaimonRuntimeIdentitySchema = z export interface LocalDaimonRuntimeIdentity { capabilityReceipt: string; - imageArchitecture: "amd64"; + imageArchitecture: "amd64" | "arm64"; imageConfigDigest: string; imageManifestDigest: string; imageReference: string; diff --git a/src/runtime/usageLedger.ts b/src/runtime/usageLedger.ts index cc58ccde..079cef35 100644 --- a/src/runtime/usageLedger.ts +++ b/src/runtime/usageLedger.ts @@ -1,6 +1,6 @@ /** * Pure reader/aggregator for Daimon's per-turn usage ledger - * (`noopolis.daimon.turn-usage.v1`, see `USAGE_ACCOUNTING_DESIGN.md`). This + * (`noopolis.daimon.turn-usage.v1`, see `specs/USAGE_ACCOUNTING_DESIGN.md`). This * module never touches Docker, the filesystem, or a deployment record — it * only knows how to parse ledger text and window/group already-parsed * records. The transport (deciding whether to `docker exec` or fall back to diff --git a/src/runtime/usageLedgerRead.ts b/src/runtime/usageLedgerRead.ts index c9ceb0a4..fbe067bb 100644 --- a/src/runtime/usageLedgerRead.ts +++ b/src/runtime/usageLedgerRead.ts @@ -1,6 +1,6 @@ /** * Transport half of Daimon's per-turn usage ledger reader - * (`noopolis.daimon.turn-usage.v1`, see `USAGE_ACCOUNTING_DESIGN.md`). Split + * (`noopolis.daimon.turn-usage.v1`, see `specs/USAGE_ACCOUNTING_DESIGN.md`). Split * out of `usageLedger.ts` so that file stays a pure parser/aggregator and this * one owns the single I/O-shaped concern: `cat` two ledger generations through * a caller-supplied `exec` and decide, per generation, whether a failed read